diff --git a/docs/superpowers/plans/2026-03-11-foundation-room-system.md b/docs/superpowers/plans/2026-03-11-foundation-room-system.md new file mode 100644 index 0000000..77464d6 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-foundation-room-system.md @@ -0,0 +1,3106 @@ +# ESC Party App — Foundation + Room System + +> **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:** Set up the monorepo, shared types, server with WebSocket, client with routing, and a fully working room system (create, join, reconnect, advance acts, end room). + +**Architecture:** Bun workspace monorepo with three packages (shared, server, client). Server is the single source of truth — in-memory room state with PostgreSQL persistence. Client is a thin real-time UI driven by WebSocket state updates. Room creation via HTTP; all real-time interactions via WebSocket. + +**Tech Stack:** Bun, Hono + @hono/node-ws, Drizzle + PostgreSQL, React 19 + Vite + Tailwind v4 + shadcn/ui, TanStack Router, Zustand, Zod, Biome, Vitest + +**Deviations from standard stack (per spec):** +- No PGlite on client — server-authoritative, Zustand holds received room state +- No Better Auth — simple UUID session tokens in `sessionStorage` +- No pg-boss — no background jobs needed +- `@hono/node-server` + `@hono/node-ws` for runtime-agnostic WebSocket (Node 22 on Uberspace) + +--- + +## Plan Series + +This is **Plan 1 of 5:** +1. **Foundation + Room System** ← this plan +2. Act 1 Games (Predictions + Dishes) +3. Act 2 Games (Jury Voting + Bingo) +4. Act 3 Games (Quiz + Final Leaderboard) +5. Polish + Deployment + +--- + +## File Structure + +``` +celebrate-esc/ +├── .mise.toml +├── .gitignore +├── .env.example +├── biome.json +├── package.json # Bun workspace root +├── tsconfig.json # Base tsconfig +├── packages/ +│ ├── shared/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ ├── index.ts # Re-exports everything +│ │ ├── constants.ts # MAX_PLAYERS, ROOM_CODE_LENGTH, etc. +│ │ ├── room-types.ts # Room, Player, Act types +│ │ └── ws-messages.ts # All WS message Zod schemas + types +│ ├── server/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── drizzle.config.ts +│ │ ├── src/ +│ │ │ ├── index.ts # Entry point (serve + inject WS) +│ │ │ ├── app.ts # Hono app + node-ws setup +│ │ │ ├── env.ts # Zod env validation +│ │ │ ├── db/ +│ │ │ │ ├── client.ts # Drizzle singleton +│ │ │ │ └── schema.ts # All Drizzle table definitions +│ │ │ ├── rooms/ +│ │ │ │ ├── room-manager.ts # In-memory state + WS broadcasting +│ │ │ │ └── room-service.ts # DB persistence layer +│ │ │ └── ws/ +│ │ │ └── handler.ts # WS route + message routing +│ │ ├── data/ +│ │ │ └── scoring.json # Point values (placeholder for Plan 1) +│ │ └── tests/ +│ │ ├── room-manager.test.ts +│ │ └── ws-handler.test.ts +│ └── client/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── vite.config.ts +│ ├── index.html +│ ├── components.json # shadcn/ui config +│ └── src/ +│ ├── main.tsx # React entry point +│ ├── app.css # Tailwind v4 imports +│ ├── lib/ +│ │ └── utils.ts # cn() utility +│ ├── stores/ +│ │ └── room-store.ts # Zustand: room state + WS connection +│ ├── hooks/ +│ │ └── use-websocket.ts # WebSocket connection hook +│ ├── routes/ +│ │ ├── __root.tsx +│ │ ├── index.tsx # Landing page +│ │ ├── display.$roomCode.tsx # TV display view +│ │ ├── host.$roomCode.tsx # Host phone view +│ │ └── play.$roomCode.tsx # Player phone view +│ └── components/ +│ ├── ui/ # shadcn components (auto-generated) +│ ├── player-list.tsx +│ └── room-header.tsx +``` + +--- + +## Chunk 1: Project Scaffolding + +### Task 1: Root configuration files + +**Files:** +- Create: `.mise.toml` +- Create: `.gitignore` +- Create: `.env.example` +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `biome.json` + +- [ ] **Step 1: Create `.mise.toml`** + +Check current available versions first: +```bash +mise ls-remote bun | tail -5 +mise ls-remote node | tail -5 +``` + +Then create `.mise.toml` with exact pinned versions (adjust to actual latest): + +```toml +[tools] +bun = "1.1.38" +node = "22.3.0" +``` + +Run: `mise install` + +- [ ] **Step 2: Create `.gitignore`** + +```gitignore +# mise +.mise.local.toml + +# dependencies +node_modules/ + +# environment +.env +.env.local +.env.*.local + +# build output +dist/ + +# OS +.DS_Store + +# IDE +.vscode/ +.idea/ + +``` + +- [ ] **Step 3: Create `.env.example`** + +```env +DATABASE_URL=postgresql://localhost:5433/celebrate_esc +PORT=3001 +``` + +- [ ] **Step 4: Create root `package.json`** + +```json +{ + "name": "celebrate-esc", + "private": true, + "workspaces": [ + "packages/shared", + "packages/server", + "packages/client" + ], + "scripts": { + "dev": "bun --filter '*' dev", + "dev:server": "bun --filter server dev", + "dev:client": "bun --filter client dev", + "build": "bun --filter '*' build", + "test": "bun --filter '*' test", + "lint": "biome check .", + "format": "biome check --write ." + } +} +``` + +- [ ] **Step 5: Create base `tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true + } +} +``` + +- [ ] **Step 6: Create `biome.json`** + +```json +{ + "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "organizeImports": { + "enabled": true + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + } + } + }, + "files": { + "ignore": ["node_modules", "dist", "drizzle"] + } +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add .mise.toml .gitignore .env.example package.json tsconfig.json biome.json +git commit -m "scaffold monorepo root with tooling configs" +``` + +--- + +### Task 2: Shared package scaffold + +**Files:** +- Create: `packages/shared/package.json` +- Create: `packages/shared/tsconfig.json` +- Create: `packages/shared/src/index.ts` + +- [ ] **Step 1: Create `packages/shared/package.json`** + +```json +{ + "name": "@celebrate-esc/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "tsc --noEmit", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "latest" + }, + "devDependencies": { + "typescript": "latest" + } +} +``` + +- [ ] **Step 2: Create `packages/shared/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create `packages/shared/src/index.ts`** + +```ts +export * from "./constants" +export * from "./room-types" +export * from "./ws-messages" +``` + +Note: this file will fail typecheck until the source files are created in Chunk 2. That's expected. + +- [ ] **Step 4: Commit** + +```bash +git add packages/shared/ +git commit -m "scaffold shared package" +``` + +--- + +### Task 3: Server package scaffold + +**Files:** +- Create: `packages/server/package.json` +- Create: `packages/server/tsconfig.json` + +- [ ] **Step 1: Create `packages/server/package.json`** + +```json +{ + "name": "@celebrate-esc/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --env-file=../../.env --watch src/index.ts", + "build": "tsc --noEmit", + "start": "node --experimental-strip-types src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@celebrate-esc/shared": "workspace:*", + "@hono/node-server": "latest", + "@hono/node-ws": "latest", + "@hono/zod-validator": "latest", + "drizzle-orm": "latest", + "hono": "latest", + "pg": "latest", + "zod": "latest" + }, + "devDependencies": { + "@types/pg": "latest", + "drizzle-kit": "latest", + "typescript": "latest", + "vitest": "latest" + } +} +``` + +- [ ] **Step 2: Create `packages/server/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/ +git commit -m "scaffold server package" +``` + +--- + +### Task 4: Client package scaffold + +**Files:** +- Create: `packages/client/package.json` +- Create: `packages/client/tsconfig.json` +- Create: `packages/client/vite.config.ts` +- Create: `packages/client/index.html` +- Create: `packages/client/src/main.tsx` +- Create: `packages/client/src/app.css` + +- [ ] **Step 1: Create `packages/client/package.json`** + +```json +{ + "name": "@celebrate-esc/client", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@celebrate-esc/shared": "workspace:*", + "@tanstack/react-router": "latest", + "react": "latest", + "react-dom": "latest", + "zustand": "latest" + }, + "devDependencies": { + "@tailwindcss/vite": "latest", + "@tanstack/router-plugin": "latest", + "@types/react": "latest", + "@types/react-dom": "latest", + "@vitejs/plugin-react": "latest", + "tailwindcss": "latest", + "typescript": "latest", + "vite": "latest" + } +} +``` + +- [ ] **Step 2: Create `packages/client/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "dist", + "rootDir": "src", + "types": ["vite/client"] + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create `packages/client/vite.config.ts`** + +```ts +import tailwindcss from "@tailwindcss/vite" +import { TanStackRouterVite } from "@tanstack/router-plugin/vite" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [TanStackRouterVite(), react(), tailwindcss()], + server: { + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + ws: true, + }, + }, + }, +}) +``` + +Note: the Vite proxy forwards `/api` to the server and **strips the `/api` prefix** via `rewrite`. The client connects to `/api/ws/:roomCode` which becomes `/ws/:roomCode` on the server. This mirrors production where Uberspace's `--remove-prefix` strips `/celebrate-esc/api`. The `ws: true` flag enables WebSocket proxying. + +- [ ] **Step 4: Create `packages/client/index.html`** + +```html + + + + + + ESC Party + + +
+ + + +``` + +- [ ] **Step 5: Create `packages/client/src/app.css`** + +```css +@import "tailwindcss"; +``` + +- [ ] **Step 6: Create `packages/client/src/main.tsx` (placeholder)** + +```tsx +import "./app.css" + +function App() { + return
ESC Party — loading...
+} + +const root = document.getElementById("root")! +import { createRoot } from "react-dom/client" +createRoot(root).render() +``` + +This is a temporary placeholder — will be replaced when TanStack Router is set up in Chunk 5. + +- [ ] **Step 7: Install dependencies + verify** + +```bash +cd celebrate-esc # project root +bun install +``` + +Expected: no errors, `node_modules/` created, `bun.lockb` created. + +Verify the client dev server starts: +```bash +cd packages/client && bun dev & +# Wait a few seconds, then check http://localhost:5173 shows "ESC Party — loading..." +# Kill the dev server +kill %1 +``` + +- [ ] **Step 8: Commit** + +```bash +git add packages/client/ bun.lockb +git commit -m "scaffold client package with Vite, Tailwind v4, TanStack Router" +``` + +--- + +## Chunk 2: Shared Types & Schemas + +### Task 5: Constants + +**Files:** +- Create: `packages/shared/src/constants.ts` + +- [ ] **Step 1: Create constants file** + +```ts +export const MAX_PLAYERS = 10 +export const ROOM_CODE_LENGTH = 4 +export const ROOM_EXPIRY_HOURS = 12 + +/** Characters used for room codes — excludes I/O/0/1 to avoid confusion */ +export const ROOM_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +export const ACTS = ["lobby", "act1", "act2", "act3", "ended"] as const +export type Act = (typeof ACTS)[number] + +/** Rating range for jury voting (Eurovision convention: 1-12) */ +export const JURY_RATING_MIN = 1 +export const JURY_RATING_MAX = 12 + +/** Bingo grid dimensions */ +export const BINGO_GRID_SIZE = 4 +export const BINGO_TOTAL_SQUARES = BINGO_GRID_SIZE * BINGO_GRID_SIZE +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/shared/src/constants.ts +git commit -m "add shared constants" +``` + +--- + +### Task 6: Room and player types + +**Files:** +- Create: `packages/shared/src/room-types.ts` + +- [ ] **Step 1: Create room types** + +```ts +import { z } from "zod" +import { ACTS, type Act } from "./constants" + +export const playerSchema = z.object({ + id: z.string().uuid(), + sessionId: z.string().uuid(), + displayName: z.string().min(1).max(20), + isHost: z.boolean(), + connected: z.boolean(), +}) + +export type Player = z.infer + +export const roomStateSchema = z.object({ + id: z.string().uuid(), + code: z.string().length(4), + currentAct: z.enum(ACTS), + hostSessionId: z.string().uuid(), + players: z.array(playerSchema), + createdAt: z.string().datetime(), + expiresAt: z.string().datetime(), +}) + +export type RoomState = z.infer + +/** Client-side room state — includes the current player's session info */ +export interface ClientRoomState { + room: RoomState | null + mySessionId: string | null + myPlayerId: string | null + isHost: boolean + connectionStatus: "disconnected" | "connecting" | "connected" +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/shared/src/room-types.ts +git commit -m "add room and player Zod schemas" +``` + +--- + +### Task 7: WebSocket message schemas + +**Files:** +- Create: `packages/shared/src/ws-messages.ts` + +- [ ] **Step 1: Create WS message schemas** + +This file defines all message types for the room system. Game-specific messages will be added in later plans. + +**Design note:** The spec lists `roomCode` in `join_room` and `reconnect` payloads. We omit it from the message schemas because the room code is already part of the WebSocket URL path (`/ws/:roomCode`). Including it in the message body would be redundant. + +```ts +import { z } from "zod" +import { ACTS } from "./constants" +import { playerSchema, roomStateSchema } from "./room-types" + +// ─── Client → Server ─────────────────────────────────────────────── + +export const joinRoomMessage = z.object({ + type: z.literal("join_room"), + displayName: z.string().min(1).max(20), +}) + +export const reconnectMessage = z.object({ + type: z.literal("reconnect"), + sessionId: z.string().uuid(), +}) + +export const advanceActMessage = z.object({ + type: z.literal("advance_act"), +}) + +export const endRoomMessage = z.object({ + type: z.literal("end_room"), +}) + +/** Union of all client → server messages (room system only — games add more) */ +export const clientMessage = z.discriminatedUnion("type", [ + joinRoomMessage, + reconnectMessage, + advanceActMessage, + endRoomMessage, +]) + +export type ClientMessage = z.infer + +// ─── Server → Client ─────────────────────────────────────────────── + +export const roomStateMessage = z.object({ + type: z.literal("room_state"), + room: roomStateSchema, + sessionId: z.string().uuid().optional(), +}) + +export const playerJoinedMessage = z.object({ + type: z.literal("player_joined"), + player: playerSchema, +}) + +export const playerDisconnectedMessage = z.object({ + type: z.literal("player_disconnected"), + playerId: z.string().uuid(), +}) + +export const playerReconnectedMessage = z.object({ + type: z.literal("player_reconnected"), + playerId: z.string().uuid(), +}) + +export const actChangedMessage = z.object({ + type: z.literal("act_changed"), + newAct: z.enum(ACTS), +}) + +export const roomEndedMessage = z.object({ + type: z.literal("room_ended"), +}) + +export const errorMessage = z.object({ + type: z.literal("error"), + message: z.string(), +}) + +/** Union of all server → client messages (room system only) */ +export const serverMessage = z.discriminatedUnion("type", [ + roomStateMessage, + playerJoinedMessage, + playerDisconnectedMessage, + playerReconnectedMessage, + actChangedMessage, + roomEndedMessage, + errorMessage, +]) + +export type ServerMessage = z.infer +``` + +- [ ] **Step 2: Verify typecheck passes** + +```bash +cd packages/shared && bun run typecheck +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/shared/src/ws-messages.ts +git commit -m "add WebSocket message Zod schemas for room system" +``` + +--- + +## Chunk 3: Server Foundation + +### Task 8: Environment validation + +**Files:** +- Create: `packages/server/src/env.ts` + +- [ ] **Step 1: Create env validation** + +```ts +import { z } from "zod" + +const envSchema = z.object({ + DATABASE_URL: z.string().url(), + PORT: z.coerce.number().default(3001), +}) + +export const env = envSchema.parse(process.env) +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/server/src/env.ts +git commit -m "add server env validation" +``` + +--- + +### Task 9: Drizzle schema + +**Files:** +- Create: `packages/server/src/db/schema.ts` +- Create: `packages/server/src/db/client.ts` +- Create: `packages/server/drizzle.config.ts` + +- [ ] **Step 1: Create Drizzle schema** + +Defines all tables from the spec. Game-specific tables are included now so migrations don't need to be regenerated in later plans. + +```ts +import { boolean, integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core" + +export const actEnum = pgEnum("act", ["lobby", "act1", "act2", "act3", "ended"]) +export const juryRoundStatusEnum = pgEnum("jury_round_status", ["open", "closed"]) +export const quizRoundStatusEnum = pgEnum("quiz_round_status", ["showing", "buzzing", "judging", "resolved"]) + +// ─── Room System ──────────────────────────────────────────────────── + +export const rooms = pgTable("rooms", { + id: uuid("id").primaryKey().defaultRandom(), + code: varchar("code", { length: 4 }).notNull().unique(), + currentAct: actEnum("current_act").notNull().default("lobby"), + hostSessionId: uuid("host_session_id").notNull(), + actualWinner: varchar("actual_winner"), + actualSecond: varchar("actual_second"), + actualThird: varchar("actual_third"), + actualLast: varchar("actual_last"), + createdAt: timestamp("created_at").notNull().defaultNow(), + expiresAt: timestamp("expires_at").notNull(), +}) + +export const players = pgTable("players", { + id: uuid("id").primaryKey().defaultRandom(), + roomId: uuid("room_id") + .notNull() + .references(() => rooms.id), + sessionId: uuid("session_id").notNull().unique(), + displayName: varchar("display_name", { length: 20 }).notNull(), + isHost: boolean("is_host").notNull().default(false), + connected: boolean("connected").notNull().default(false), + joinedAt: timestamp("joined_at").notNull().defaultNow(), +}) + +// ─── Predictions (Plan 2) ────────────────────────────────────────── + +export const predictions = pgTable("predictions", { + id: uuid("id").primaryKey().defaultRandom(), + playerId: uuid("player_id") + .notNull() + .references(() => players.id), + roomId: uuid("room_id") + .notNull() + .references(() => rooms.id), + predictedWinner: varchar("predicted_winner").notNull(), + top3: jsonb("top_3").notNull().$type(), + nulPointsPick: varchar("nul_points_pick").notNull(), +}) + +// ─── Jury Voting (Plan 3) ────────────────────────────────────────── + +export const juryRounds = pgTable("jury_rounds", { + id: uuid("id").primaryKey().defaultRandom(), + roomId: uuid("room_id") + .notNull() + .references(() => rooms.id), + countryCode: varchar("country_code").notNull(), + status: juryRoundStatusEnum("status").notNull().default("open"), + openedAt: timestamp("opened_at").notNull().defaultNow(), +}) + +export const juryVotes = pgTable("jury_votes", { + id: uuid("id").primaryKey().defaultRandom(), + playerId: uuid("player_id") + .notNull() + .references(() => players.id), + juryRoundId: uuid("jury_round_id") + .notNull() + .references(() => juryRounds.id), + rating: integer("rating").notNull(), +}) + +// ─── Bingo (Plan 3) ──────────────────────────────────────────────── + +export const bingoCards = pgTable("bingo_cards", { + id: uuid("id").primaryKey().defaultRandom(), + playerId: uuid("player_id") + .notNull() + .references(() => players.id), + roomId: uuid("room_id") + .notNull() + .references(() => rooms.id), + squares: jsonb("squares").notNull().$type<{ tropeId: string; tapped: boolean }[]>(), +}) + +// ─── Dishes (Plan 2) ─────────────────────────────────────────────── + +export const dishes = pgTable("dishes", { + id: uuid("id").primaryKey().defaultRandom(), + roomId: uuid("room_id") + .notNull() + .references(() => rooms.id), + name: varchar("name", { length: 100 }).notNull(), + correctCountry: varchar("correct_country").notNull(), + revealed: boolean("revealed").notNull().default(false), +}) + +export const dishGuesses = pgTable("dish_guesses", { + id: uuid("id").primaryKey().defaultRandom(), + playerId: uuid("player_id") + .notNull() + .references(() => players.id), + dishId: uuid("dish_id") + .notNull() + .references(() => dishes.id), + guessedCountry: varchar("guessed_country").notNull(), +}) + +// ─── Quiz (Plan 4) ───────────────────────────────────────────────── + +export const quizRounds = pgTable("quiz_rounds", { + id: uuid("id").primaryKey().defaultRandom(), + roomId: uuid("room_id") + .notNull() + .references(() => rooms.id), + questionId: varchar("question_id").notNull(), + status: quizRoundStatusEnum("status").notNull().default("showing"), +}) + +export const quizAnswers = pgTable("quiz_answers", { + id: uuid("id").primaryKey().defaultRandom(), + playerId: uuid("player_id") + .notNull() + .references(() => players.id), + quizRoundId: uuid("quiz_round_id") + .notNull() + .references(() => quizRounds.id), + buzzedAt: timestamp("buzzed_at").notNull().defaultNow(), + correct: boolean("correct"), +}) +``` + +- [ ] **Step 2: Create DB client** + +```ts +import { drizzle } from "drizzle-orm/node-postgres" +import * as schema from "./schema" + +export function createDb(databaseUrl: string) { + return drizzle(databaseUrl, { schema }) +} + +export type Database = ReturnType +``` + +- [ ] **Step 3: Create Drizzle config** + +```ts +import { defineConfig } from "drizzle-kit" + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}) +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/server/src/db/ packages/server/drizzle.config.ts +git commit -m "add Drizzle schema with all tables, DB client" +``` + +--- + +### Task 10: Hono app + entry point + +**Files:** +- Create: `packages/server/src/app.ts` +- Create: `packages/server/src/index.ts` + +- [ ] **Step 1: Create Hono app** + +```ts +import { Hono } from "hono" +import { cors } from "hono/cors" +import { createNodeWebSocket } from "@hono/node-ws" + +const app = new Hono() + +app.use("*", cors()) + +const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) + +app.get("/health", (c) => c.json({ status: "ok" })) + +export { app, injectWebSocket, upgradeWebSocket } +``` + +- [ ] **Step 2: Create entry point** + +```ts +import { serve } from "@hono/node-server" +import { app, injectWebSocket } from "./app" +import { env } from "./env" + +const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { + console.log(`celebrate-esc server running on http://localhost:${info.port}`) +}) + +injectWebSocket(server) +``` + +- [ ] **Step 3: Create `.env` for local development** + +Create a root `.env` (not committed — gitignored): +```bash +cp .env.example .env +# Edit .env with your local PostgreSQL URL +``` + +The server's dev script (already set in Task 3) loads this file: +```json +"dev": "bun --env-file=../../.env --watch src/index.ts" +``` + +- [ ] **Step 4: Verify server starts** + +Ensure PostgreSQL is running locally, create the database, then start the server: + +```bash +createdb -h localhost -p 5433 celebrate_esc 2>/dev/null || true +cd packages/server && DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun src/index.ts & +curl http://localhost:3001/health +# Expected: {"status":"ok"} +kill %1 +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/app.ts packages/server/src/index.ts packages/server/package.json +git commit -m "add Hono app with health endpoint, server entry point" +``` + +--- + +### Task 11: Generate and apply initial migration + +- [ ] **Step 1: Generate migration** + +```bash +cd packages/server +DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun drizzle-kit generate +``` + +Expected: migration file created in `packages/server/drizzle/` directory. + +- [ ] **Step 2: Apply migration** + +```bash +cd packages/server +DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun drizzle-kit migrate +``` + +Expected: tables created in the database. + +- [ ] **Step 3: Verify tables exist** + +```bash +psql -h localhost -p 5433 celebrate_esc -c "\dt" +``` + +Expected: rooms, players, predictions, jury_rounds, jury_votes, bingo_cards, dishes, dish_guesses, quiz_rounds, quiz_answers tables listed. + +- [ ] **Step 4: Create scoring.json placeholder** + +```bash +mkdir -p packages/server/data +``` + +Create `packages/server/data/scoring.json`: + +```json +{ + "prediction_winner": 25, + "prediction_top3": 10, + "prediction_nul_points": 15, + "jury_max_per_round": 5, + "bingo_per_square": 2, + "bingo_full_bonus": 10, + "quiz_easy": 5, + "quiz_medium": 10, + "quiz_hard": 15, + "dish_correct": 5 +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/drizzle/ packages/server/data/ +git commit -m "generate initial Drizzle migration, add scoring config" +``` + +--- + +## Chunk 4: Room System + +### Task 12: Room manager — write failing tests + +**Files:** +- Create: `packages/server/tests/room-manager.test.ts` + +- [ ] **Step 1: Write room manager tests** + +```ts +import { describe, expect, it, beforeEach } from "vitest" +import { RoomManager } from "../src/rooms/room-manager" +import type { Act } from "@celebrate-esc/shared" + +describe("RoomManager", () => { + let manager: RoomManager + + beforeEach(() => { + manager = new RoomManager() + }) + + describe("createRoom", () => { + it("returns a 4-character room code and session ID", () => { + const result = manager.createRoom("Host") + expect(result.code).toMatch(/^[A-Z0-9]{4}$/) + expect(result.sessionId).toBeDefined() + expect(result.sessionId.length).toBe(36) // UUID + }) + + it("creates the host as a player in the room", () => { + const { code, sessionId } = manager.createRoom("Host") + const room = manager.getRoom(code) + expect(room).toBeDefined() + expect(room!.players).toHaveLength(1) + expect(room!.players[0]!.displayName).toBe("Host") + expect(room!.players[0]!.isHost).toBe(true) + expect(room!.players[0]!.sessionId).toBe(sessionId) + }) + + it("starts in lobby state", () => { + const { code } = manager.createRoom("Host") + const room = manager.getRoom(code) + expect(room!.currentAct).toBe("lobby") + }) + + it("generates unique room codes", () => { + const codes = new Set() + for (let i = 0; i < 50; i++) { + const { code } = manager.createRoom(`Host ${i}`) + codes.add(code) + } + expect(codes.size).toBe(50) + }) + }) + + describe("joinRoom", () => { + it("adds a player to an existing room", () => { + const { code } = manager.createRoom("Host") + const result = manager.joinRoom(code, "Player 1") + + expect("sessionId" in result).toBe(true) + if ("sessionId" in result) { + const room = manager.getRoom(code) + expect(room!.players).toHaveLength(2) + expect(room!.players[1]!.displayName).toBe("Player 1") + expect(room!.players[1]!.isHost).toBe(false) + } + }) + + it("rejects join if room not found", () => { + const result = manager.joinRoom("ZZZZ", "Player") + expect(result).toEqual({ error: "Room not found" }) + }) + + it("rejects join if room has ended", () => { + const { code } = manager.createRoom("Host") + // Force room to ended state + manager.advanceAct(code, manager.getRoom(code)!.hostSessionId) + manager.advanceAct(code, manager.getRoom(code)!.hostSessionId) + manager.advanceAct(code, manager.getRoom(code)!.hostSessionId) + manager.advanceAct(code, manager.getRoom(code)!.hostSessionId) + const result = manager.joinRoom(code, "Late Player") + expect(result).toEqual({ error: "Room has ended" }) + }) + + it("rejects join if display name is taken", () => { + const { code } = manager.createRoom("Host") + manager.joinRoom(code, "Player 1") + const result = manager.joinRoom(code, "Player 1") + expect(result).toEqual({ error: "Name already taken" }) + }) + + it("rejects join if room is full (10 players)", () => { + const { code } = manager.createRoom("Host") + for (let i = 1; i <= 9; i++) { + manager.joinRoom(code, `Player ${i}`) + } + const result = manager.joinRoom(code, "Player 10") + expect(result).toEqual({ error: "Room is full" }) + }) + }) + + describe("advanceAct", () => { + it("advances through acts in order", () => { + const { code } = manager.createRoom("Host") + const room = manager.getRoom(code)! + const hostSession = room.hostSessionId + + const expectedSequence: Act[] = ["act1", "act2", "act3", "ended"] + for (const expected of expectedSequence) { + const result = manager.advanceAct(code, hostSession) + expect(result).toEqual({ newAct: expected }) + expect(manager.getRoom(code)!.currentAct).toBe(expected) + } + }) + + it("cannot advance past ended", () => { + const { code } = manager.createRoom("Host") + const room = manager.getRoom(code)! + // Advance to ended + for (let i = 0; i < 4; i++) { + manager.advanceAct(code, room.hostSessionId) + } + const result = manager.advanceAct(code, room.hostSessionId) + expect(result).toEqual({ error: "Room has already ended" }) + }) + + it("rejects advance from non-host", () => { + const { code } = manager.createRoom("Host") + const joinResult = manager.joinRoom(code, "Player") + if ("sessionId" in joinResult) { + const result = manager.advanceAct(code, joinResult.sessionId) + expect(result).toEqual({ error: "Only the host can advance acts" }) + } + }) + }) + + describe("endRoom", () => { + it("sets room to ended state", () => { + const { code } = manager.createRoom("Host") + const room = manager.getRoom(code)! + const result = manager.endRoom(code, room.hostSessionId) + expect(result).toEqual({ success: true }) + expect(manager.getRoom(code)!.currentAct).toBe("ended") + }) + + it("rejects end from non-host", () => { + const { code } = manager.createRoom("Host") + const joinResult = manager.joinRoom(code, "Player") + if ("sessionId" in joinResult) { + const result = manager.endRoom(code, joinResult.sessionId) + expect(result).toEqual({ error: "Only the host can end the room" }) + } + }) + }) + + describe("getRoom", () => { + it("returns null for non-existent room", () => { + expect(manager.getRoom("ZZZZ")).toBeNull() + }) + + it("returns serialized room state", () => { + const { code } = manager.createRoom("Host") + const room = manager.getRoom(code) + expect(room).toBeDefined() + expect(room!.code).toBe(code) + expect(room!.currentAct).toBe("lobby") + expect(Array.isArray(room!.players)).toBe(true) + }) + }) + + describe("reconnect", () => { + it("re-identifies an existing player by session ID", () => { + const { code, sessionId } = manager.createRoom("Host") + const result = manager.reconnectPlayer(code, sessionId) + expect(result).toEqual({ success: true, playerId: expect.any(String) }) + }) + + it("rejects reconnect with unknown session ID", () => { + const { code } = manager.createRoom("Host") + const result = manager.reconnectPlayer(code, "00000000-0000-0000-0000-000000000000") + expect(result).toEqual({ error: "Session not found in this room" }) + }) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd packages/server && bun test tests/room-manager.test.ts +``` + +Expected: FAIL — `RoomManager` module not found. + +- [ ] **Step 3: Commit failing tests** + +```bash +git add packages/server/tests/room-manager.test.ts +git commit -m "add room manager tests (red)" +``` + +--- + +### Task 13: Room manager — implement + +**Files:** +- Create: `packages/server/src/rooms/room-manager.ts` + +- [ ] **Step 1: Implement RoomManager** + +```ts +import { randomUUID } from "node:crypto" +import { ACTS, MAX_PLAYERS, ROOM_CODE_CHARS, ROOM_CODE_LENGTH, ROOM_EXPIRY_HOURS } from "@celebrate-esc/shared" +import type { Act, RoomState, Player } from "@celebrate-esc/shared" + +interface InternalPlayer { + id: string + sessionId: string + displayName: string + isHost: boolean + connected: boolean +} + +interface InternalRoom { + id: string + code: string + currentAct: Act + hostSessionId: string + players: Map // sessionId → player + createdAt: Date + expiresAt: Date +} + +export class RoomManager { + private rooms = new Map() + + createRoom(hostDisplayName: string): { code: string; sessionId: string } { + const code = this.generateRoomCode() + const sessionId = randomUUID() + const now = new Date() + + const host: InternalPlayer = { + id: randomUUID(), + sessionId, + displayName: hostDisplayName, + isHost: true, + connected: false, + } + + const room: InternalRoom = { + id: randomUUID(), + code, + currentAct: "lobby", + hostSessionId: sessionId, + players: new Map([[sessionId, host]]), + createdAt: now, + expiresAt: new Date(now.getTime() + ROOM_EXPIRY_HOURS * 60 * 60 * 1000), + } + + this.rooms.set(code, room) + return { code, sessionId } + } + + joinRoom(code: string, displayName: string): { sessionId: string } | { error: string } { + const room = this.rooms.get(code) + if (!room) return { error: "Room not found" } + if (room.currentAct === "ended") return { error: "Room has ended" } + if (room.players.size >= MAX_PLAYERS) return { error: "Room is full" } + + for (const player of room.players.values()) { + if (player.displayName.toLowerCase() === displayName.toLowerCase()) return { error: "Name already taken" } + } + + const sessionId = randomUUID() + room.players.set(sessionId, { + id: randomUUID(), + sessionId, + displayName, + isHost: false, + connected: false, + }) + + return { sessionId } + } + + advanceAct(code: string, sessionId: string): { newAct: Act } | { error: string } { + const room = this.rooms.get(code) + if (!room) return { error: "Room not found" } + if (room.hostSessionId !== sessionId) return { error: "Only the host can advance acts" } + if (room.currentAct === "ended") return { error: "Room has already ended" } + + const currentIndex = ACTS.indexOf(room.currentAct) + const nextAct = ACTS[currentIndex + 1]! + room.currentAct = nextAct + return { newAct: nextAct } + } + + endRoom(code: string, sessionId: string): { success: true } | { error: string } { + const room = this.rooms.get(code) + if (!room) return { error: "Room not found" } + if (room.hostSessionId !== sessionId) return { error: "Only the host can end the room" } + + room.currentAct = "ended" + return { success: true } + } + + reconnectPlayer(code: string, sessionId: string): { success: true; playerId: string } | { error: string } { + const room = this.rooms.get(code) + if (!room) return { error: "Room not found" } + + const player = room.players.get(sessionId) + if (!player) return { error: "Session not found in this room" } + + player.connected = true + return { success: true, playerId: player.id } + } + + setPlayerConnected(code: string, sessionId: string, connected: boolean): void { + const room = this.rooms.get(code) + if (!room) return + + const player = room.players.get(sessionId) + if (player) { + player.connected = connected + } + } + + getRoom(code: string): RoomState | null { + const room = this.rooms.get(code) + if (!room) return null + + return { + id: room.id, + code: room.code, + currentAct: room.currentAct, + hostSessionId: room.hostSessionId, + players: Array.from(room.players.values()).map((p) => ({ + id: p.id, + sessionId: p.sessionId, + displayName: p.displayName, + isHost: p.isHost, + connected: p.connected, + })), + createdAt: room.createdAt.toISOString(), + expiresAt: room.expiresAt.toISOString(), + } + } + + isHost(code: string, sessionId: string): boolean { + const room = this.rooms.get(code) + return room?.hostSessionId === sessionId + } + + /** Clear all rooms — used in tests */ + reset(): void { + this.rooms.clear() + } + + private generateRoomCode(): string { + let code: string + do { + code = Array.from( + { length: ROOM_CODE_LENGTH }, + () => ROOM_CODE_CHARS[Math.floor(Math.random() * ROOM_CODE_CHARS.length)]!, + ).join("") + } while (this.rooms.has(code)) + return code + } +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +```bash +cd packages/server && bun test tests/room-manager.test.ts +``` + +Expected: all tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/src/rooms/room-manager.ts +git commit -m "implement room manager, pass all room tests" +``` + +--- + +### Task 14: Room service — DB persistence + +**Files:** +- Create: `packages/server/src/rooms/room-service.ts` + +- [ ] **Step 1: Implement room service** + +This layer persists room and player state to PostgreSQL. It is called by the WebSocket handler after in-memory state changes. + +```ts +import { eq } from "drizzle-orm" +import type { Database } from "../db/client" +import { players, rooms } from "../db/schema" + +export class RoomService { + constructor(private db: Database) {} + + async persistRoom(room: { + id: string + code: string + currentAct: string + hostSessionId: string + expiresAt: Date + }) { + await this.db + .insert(rooms) + .values({ + id: room.id, + code: room.code, + currentAct: room.currentAct as "lobby" | "act1" | "act2" | "act3" | "ended", + hostSessionId: room.hostSessionId, + expiresAt: room.expiresAt, + }) + .onConflictDoUpdate({ + target: rooms.id, + set: { + currentAct: room.currentAct as "lobby" | "act1" | "act2" | "act3" | "ended", + }, + }) + } + + async persistPlayer(player: { + id: string + roomId: string + sessionId: string + displayName: string + isHost: boolean + }) { + await this.db + .insert(players) + .values({ + id: player.id, + roomId: player.roomId, + sessionId: player.sessionId, + displayName: player.displayName, + isHost: player.isHost, + }) + .onConflictDoUpdate({ + target: players.id, + set: { + connected: true, + }, + }) + } + + async updateRoomAct(roomId: string, act: string) { + await this.db + .update(rooms) + .set({ currentAct: act as "lobby" | "act1" | "act2" | "act3" | "ended" }) + .where(eq(rooms.id, roomId)) + } + + async updatePlayerConnected(sessionId: string, connected: boolean) { + await this.db.update(players).set({ connected }).where(eq(players.sessionId, sessionId)) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/server/src/rooms/room-service.ts +git commit -m "add room service for DB persistence" +``` + +--- + +### Task 15: WebSocket handler + HTTP room creation + +**Files:** +- Create: `packages/server/src/ws/handler.ts` +- Modify: `packages/server/src/app.ts` +- Modify: `packages/server/src/index.ts` + +- [ ] **Step 1: Create WebSocket handler** + +```ts +import type { WSContext } from "hono/ws" +import { clientMessage } from "@celebrate-esc/shared" +import type { ServerMessage } from "@celebrate-esc/shared" +import { app, upgradeWebSocket } from "../app" +import { roomManager } from "../rooms/index" + +// Track all WebSocket connections per room +// roomCode → Set of { ws, sessionId | null } +interface Connection { + ws: WSContext + sessionId: string | null +} +const roomConnections = new Map>() + +function getConnections(roomCode: string): Set { + let conns = roomConnections.get(roomCode) + if (!conns) { + conns = new Set() + roomConnections.set(roomCode, conns) + } + return conns +} + +function broadcast(roomCode: string, message: ServerMessage) { + const data = JSON.stringify(message) + const conns = roomConnections.get(roomCode) + if (!conns) return + for (const conn of conns) { + try { + conn.ws.send(data) + } catch { + // Connection may be closed — will be cleaned up on onClose + } + } +} + +function sendTo(ws: WSContext, message: ServerMessage) { + ws.send(JSON.stringify(message)) +} + +function sendError(ws: WSContext, message: string) { + sendTo(ws, { type: "error", message }) +} + +export function registerWebSocketRoutes() { + app.get( + "/ws/:roomCode", + upgradeWebSocket((c) => { + const roomCode = c.req.param("roomCode") + let sessionId: string | null = c.req.query("sessionId") ?? null + let connection: Connection | null = null + + return { + onOpen(_event, ws) { + const room = roomManager.getRoom(roomCode) + if (!room) { + sendError(ws, "Room not found") + ws.close(4004, "Room not found") + return + } + + connection = { ws, sessionId } + getConnections(roomCode).add(connection) + + // If sessionId provided, attempt reconnect + if (sessionId) { + const result = roomManager.reconnectPlayer(roomCode, sessionId) + if ("error" in result) { + sendError(ws, result.error) + sessionId = null + connection.sessionId = null + } else { + roomManager.setPlayerConnected(roomCode, sessionId, true) + sendTo(ws, { + type: "room_state", + room: roomManager.getRoom(roomCode)!, + }) + broadcast(roomCode, { + type: "player_reconnected", + playerId: result.playerId, + }) + } + } else { + // Passive viewer (display) or player about to send join_room + // Send current room state + sendTo(ws, { + type: "room_state", + room: roomManager.getRoom(roomCode)!, + }) + } + }, + + onMessage(event, ws) { + let data: unknown + try { + data = JSON.parse(typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer)) + } catch { + sendError(ws, "Invalid JSON") + return + } + + const parsed = clientMessage.safeParse(data) + if (!parsed.success) { + sendError(ws, `Invalid message: ${parsed.error.message}`) + return + } + + const msg = parsed.data + + switch (msg.type) { + case "join_room": { + if (sessionId) { + sendError(ws, "Already joined") + return + } + const result = roomManager.joinRoom(roomCode, msg.displayName) + if ("error" in result) { + sendError(ws, result.error) + return + } + sessionId = result.sessionId + if (connection) connection.sessionId = sessionId + roomManager.setPlayerConnected(roomCode, sessionId, true) + + // Send room state with session ID to the new player + sendTo(ws, { + type: "room_state", + room: roomManager.getRoom(roomCode)!, + sessionId: result.sessionId, + }) + + // Broadcast player joined to everyone + const room = roomManager.getRoom(roomCode)! + const newPlayer = room.players.find((p) => p.sessionId === sessionId)! + broadcast(roomCode, { + type: "player_joined", + player: newPlayer, + }) + break + } + + case "reconnect": { + const result = roomManager.reconnectPlayer(roomCode, msg.sessionId) + if ("error" in result) { + sendError(ws, result.error) + return + } + sessionId = msg.sessionId + if (connection) connection.sessionId = sessionId + roomManager.setPlayerConnected(roomCode, sessionId, true) + sendTo(ws, { + type: "room_state", + room: roomManager.getRoom(roomCode)!, + }) + broadcast(roomCode, { + type: "player_reconnected", + playerId: result.playerId, + }) + break + } + + case "advance_act": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + const result = roomManager.advanceAct(roomCode, sessionId) + if ("error" in result) { + sendError(ws, result.error) + return + } + broadcast(roomCode, { + type: "act_changed", + newAct: result.newAct, + }) + break + } + + case "end_room": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + const result = roomManager.endRoom(roomCode, sessionId) + if ("error" in result) { + sendError(ws, result.error) + return + } + broadcast(roomCode, { type: "room_ended" }) + break + } + } + }, + + onClose() { + if (connection) { + getConnections(roomCode).delete(connection) + } + if (sessionId) { + roomManager.setPlayerConnected(roomCode, sessionId, false) + const room = roomManager.getRoom(roomCode) + if (room) { + const player = room.players.find((p) => p.sessionId === sessionId) + if (player) { + broadcast(roomCode, { + type: "player_disconnected", + playerId: player.id, + }) + } + } + } + }, + } + }), + ) +} +``` + +- [ ] **Step 2: Create roomManager singleton and update app.ts** + +Create `packages/server/src/rooms/index.ts` — a singleton module imported by both `app.ts` and `handler.ts` (avoids circular dependencies): + +```ts +import { RoomManager } from "./room-manager" + +export const roomManager = new RoomManager() +``` + +Replace `packages/server/src/app.ts` with: + +```ts +import { Hono } from "hono" +import { cors } from "hono/cors" +import { createNodeWebSocket } from "@hono/node-ws" +import { zValidator } from "@hono/zod-validator" +import { z } from "zod" +import { roomManager } from "./rooms/index" + +const app = new Hono() + +app.use("*", cors()) + +const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) + +app.get("/health", (c) => c.json({ status: "ok" })) + +const createRoomSchema = z.object({ + displayName: z.string().min(1).max(20), +}) + +app.post("/rooms", zValidator("json", createRoomSchema), (c) => { + const { displayName } = c.req.valid("json") + const result = roomManager.createRoom(displayName) + return c.json({ data: result }) +}) + +export { app, injectWebSocket, upgradeWebSocket } +``` + +The handler.ts (Step 1) already imports `roomManager` from `../rooms/index`. + +- [ ] **Step 3: Update entry point to register WS routes** + +Modify `packages/server/src/index.ts`: + +```ts +import { serve } from "@hono/node-server" +import { app, injectWebSocket } from "./app" +import { registerWebSocketRoutes } from "./ws/handler" +import { env } from "./env" + +registerWebSocketRoutes() + +const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { + console.log(`celebrate-esc server running on http://localhost:${info.port}`) +}) + +injectWebSocket(server) +``` + +- [ ] **Step 4: Verify server starts with WS support** + +```bash +cd packages/server +DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun src/index.ts & + +# Test HTTP room creation (direct server access — no /api prefix) +curl -X POST http://localhost:3001/rooms \ + -H "Content-Type: application/json" \ + -d '{"displayName": "TestHost"}' +# Expected: {"data":{"code":"XXXX","sessionId":"uuid"}} + +kill %1 +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/ws/ packages/server/src/rooms/index.ts packages/server/src/app.ts packages/server/src/index.ts +git commit -m "add WebSocket handler, HTTP room creation, message routing" +``` + +--- + +### Task 16: WebSocket integration test + +**Files:** +- Create: `packages/server/tests/ws-handler.test.ts` + +- [ ] **Step 1: Write WebSocket integration test** + +This test starts the server and tests the WebSocket flow end-to-end using the native WebSocket client. + +```ts +import { describe, expect, it, afterEach, beforeEach } from "vitest" +import { serve } from "@hono/node-server" +import type { Server } from "node:http" +import { app, injectWebSocket } from "../src/app" +import { registerWebSocketRoutes } from "../src/ws/handler" +import { roomManager } from "../src/rooms/index" + +// Register WS routes once +registerWebSocketRoutes() + +let server: Server + +function waitForMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.addEventListener("message", (event) => { + resolve(JSON.parse(event.data as string)) + }, { once: true }) + }) +} + +function waitForOpen(ws: WebSocket): Promise { + return new Promise((resolve) => { + if (ws.readyState === WebSocket.OPEN) { + resolve() + } else { + ws.addEventListener("open", () => resolve(), { once: true }) + } + }) +} + +describe("WebSocket handler", () => { + let port: number + + beforeEach(async () => { + roomManager.reset() + port = 3100 + Math.floor(Math.random() * 900) + server = serve({ fetch: app.fetch, port }) + injectWebSocket(server) + }) + + afterEach(() => { + server.close() + }) + + it("creates a room via HTTP and connects via WebSocket", async () => { + // Create room via HTTP + const res = await fetch(`http://localhost:${port}/rooms`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName: "Host" }), + }) + const { data } = (await res.json()) as { data: { code: string; sessionId: string } } + expect(data.code).toMatch(/^[A-Z0-9]{4}$/) + + // Connect as host via WebSocket + const ws = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`) + await waitForOpen(ws) + + const msg = await waitForMessage(ws) as { type: string; room: { code: string } } + expect(msg.type).toBe("room_state") + expect(msg.room.code).toBe(data.code) + + ws.close() + }) + + it("player joins room via WebSocket", async () => { + // Create room + const res = await fetch(`http://localhost:${port}/rooms`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName: "Host" }), + }) + const { data } = (await res.json()) as { data: { code: string; sessionId: string } } + + // Connect host + const hostWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`) + await waitForOpen(hostWs) + await waitForMessage(hostWs) // room_state + + // Connect player (no sessionId) + const playerWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}`) + await waitForOpen(playerWs) + await waitForMessage(playerWs) // initial room_state + + // Player sends join_room + playerWs.send(JSON.stringify({ type: "join_room", displayName: "Player 1" })) + + // Player receives room_state with sessionId + const playerMsg = await waitForMessage(playerWs) as { type: string; sessionId?: string } + expect(playerMsg.type).toBe("room_state") + expect(playerMsg.sessionId).toBeDefined() + + // Host receives player_joined broadcast + const hostMsg = await waitForMessage(hostWs) as { type: string; player: { displayName: string } } + expect(hostMsg.type).toBe("player_joined") + expect(hostMsg.player.displayName).toBe("Player 1") + + hostWs.close() + playerWs.close() + }) +}) +``` + +- [ ] **Step 2: Run integration tests** + +```bash +cd packages/server && bun test tests/ws-handler.test.ts +``` + +Expected: all tests PASS. + +Note: if WebSocket tests are flaky due to timing, add small delays (`await new Promise(r => setTimeout(r, 50))`). But prefer the event-based `waitForMessage` approach. + +- [ ] **Step 3: Run all tests** + +```bash +cd packages/server && bun test +``` + +Expected: all tests (room-manager + ws-handler) PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/server/tests/ws-handler.test.ts +git commit -m "add WebSocket integration tests" +``` + +--- + +## Chunk 5: Client Foundation & Views + +### Task 17: Tailwind v4 + shadcn/ui setup + +**Files:** +- Modify: `packages/client/package.json` (add shadcn deps) +- Create: `packages/client/components.json` +- Create: `packages/client/src/lib/utils.ts` + +- [ ] **Step 1: Add Vite React plugin dependency** + +Add to `packages/client/package.json` devDependencies: +```json +"@vitejs/plugin-react": "latest" +``` + +Then run `bun install` from the workspace root. + +- [ ] **Step 2: Initialize shadcn/ui** + +```bash +cd packages/client +bunx shadcn@latest init +``` + +When prompted: +- Style: Default +- Base color: Neutral +- CSS variables: Yes +- CSS file: src/app.css +- Tailwind config: (none — Tailwind v4 uses CSS) +- Components: src/components/ui +- Utils: src/lib/utils + +If the interactive init doesn't work well, create the files manually: + +Create `packages/client/components.json`: +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} +``` + +Create `packages/client/src/lib/utils.ts`: +```ts +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} +``` + +Add path aliases to `packages/client/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "dist", + "rootDir": "src", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} +``` + +Add path resolution to `packages/client/vite.config.ts`: +```ts +import path from "node:path" +import tailwindcss from "@tailwindcss/vite" +import { TanStackRouterVite } from "@tanstack/router-plugin/vite" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [TanStackRouterVite(), react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + ws: true, + }, + }, + }, +}) +``` + +- [ ] **Step 3: Add required dependencies** + +```bash +cd celebrate-esc # project root +bun add --cwd packages/client clsx tailwind-merge lucide-react +``` + +- [ ] **Step 4: Install shadcn/ui components for Plan 1** + +```bash +cd packages/client +bunx shadcn@latest add button input card badge tabs +``` + +- [ ] **Step 5: Verify client builds** + +```bash +cd packages/client && bun run build +``` + +Expected: build succeeds, output in `dist/`. + +- [ ] **Step 6: Commit** + +```bash +git add packages/client/ +bun install # Ensure lockfile is updated +git add bun.lockb +git commit -m "set up Tailwind v4, shadcn/ui, path aliases" +``` + +--- + +### Task 18: TanStack Router + routes + +**Files:** +- Modify: `packages/client/src/main.tsx` +- Create: `packages/client/src/routes/__root.tsx` +- Create: `packages/client/src/routes/index.tsx` +- Create: `packages/client/src/routes/display.$roomCode.tsx` +- Create: `packages/client/src/routes/host.$roomCode.tsx` +- Create: `packages/client/src/routes/play.$roomCode.tsx` + +- [ ] **Step 1: Update main.tsx with router** + +```tsx +import "./app.css" +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { RouterProvider, createRouter } from "@tanstack/react-router" +import { routeTree } from "./routeTree.gen" + +const router = createRouter({ routeTree }) + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router + } +} + +createRoot(document.getElementById("root")!).render( + + + , +) +``` + +- [ ] **Step 2: Create root layout** + +`packages/client/src/routes/__root.tsx`: + +```tsx +import { createRootRoute, Outlet } from "@tanstack/react-router" + +export const Route = createRootRoute({ + component: () => ( +
+ +
+ ), +}) +``` + +- [ ] **Step 3: Create landing page route (placeholder)** + +`packages/client/src/routes/index.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/")({ + component: LandingPage, +}) + +function LandingPage() { + return ( +
+

ESC Party

+
+ ) +} +``` + +- [ ] **Step 4: Create display route (placeholder)** + +`packages/client/src/routes/display.$roomCode.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/display/$roomCode")({ + component: DisplayView, +}) + +function DisplayView() { + const { roomCode } = Route.useParams() + return ( +
+

Display — Room {roomCode}

+
+ ) +} +``` + +- [ ] **Step 5: Create host route (placeholder)** + +`packages/client/src/routes/host.$roomCode.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/host/$roomCode")({ + component: HostView, +}) + +function HostView() { + const { roomCode } = Route.useParams() + return ( +
+

Host — Room {roomCode}

+
+ ) +} +``` + +- [ ] **Step 6: Create player route (placeholder)** + +`packages/client/src/routes/play.$roomCode.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/play/$roomCode")({ + component: PlayerView, +}) + +function PlayerView() { + const { roomCode } = Route.useParams() + return ( +
+

Player — Room {roomCode}

+
+ ) +} +``` + +- [ ] **Step 7: Verify routes work** + +```bash +cd packages/client && bun dev & +# Visit http://localhost:5173 — see "ESC Party" +# Visit http://localhost:5173/display/TEST — see "Display — Room TEST" +# Visit http://localhost:5173/host/TEST — see "Host — Room TEST" +# Visit http://localhost:5173/play/TEST — see "Player — Room TEST" +kill %1 +``` + +- [ ] **Step 8: Commit** + +```bash +git add packages/client/src/ +git commit -m "set up TanStack Router with landing, display, host, player routes" +``` + +--- + +### Task 19: Zustand store + WebSocket hook + +**Files:** +- Create: `packages/client/src/stores/room-store.ts` +- Create: `packages/client/src/hooks/use-websocket.ts` + +- [ ] **Step 1: Create room store** + +```ts +import { create } from "zustand" +import type { RoomState, Player } from "@celebrate-esc/shared" + +interface RoomStore { + room: RoomState | null + mySessionId: string | null + connectionStatus: "disconnected" | "connecting" | "connected" + + setRoom: (room: RoomState) => void + setMySessionId: (sessionId: string) => void + setConnectionStatus: (status: "disconnected" | "connecting" | "connected") => void + updatePlayerConnected: (playerId: string, connected: boolean) => void + addPlayer: (player: Player) => void + setAct: (act: RoomState["currentAct"]) => void + reset: () => void +} + +export const useRoomStore = create((set) => ({ + room: null, + mySessionId: null, + connectionStatus: "disconnected", + + setRoom: (room) => set({ room }), + setMySessionId: (sessionId) => set({ mySessionId: sessionId }), + setConnectionStatus: (status) => set({ connectionStatus: status }), + + updatePlayerConnected: (playerId, connected) => + set((state) => { + if (!state.room) return state + return { + room: { + ...state.room, + players: state.room.players.map((p) => (p.id === playerId ? { ...p, connected } : p)), + }, + } + }), + + addPlayer: (player) => + set((state) => { + if (!state.room) return state + // Avoid duplicates + if (state.room.players.some((p) => p.id === player.id)) return state + return { + room: { + ...state.room, + players: [...state.room.players, player], + }, + } + }), + + setAct: (act) => + set((state) => { + if (!state.room) return state + return { room: { ...state.room, currentAct: act } } + }), + + reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected" }), +})) +``` + +- [ ] **Step 2: Create WebSocket hook** + +```ts +import { useEffect, useRef, useCallback } from "react" +import type { ClientMessage, ServerMessage } from "@celebrate-esc/shared" +import { useRoomStore } from "@/stores/room-store" + +const SESSION_KEY = "esc-party-session" + +function getStoredSession(): { roomCode: string; sessionId: string } | null { + try { + const raw = sessionStorage.getItem(SESSION_KEY) + if (!raw) return null + return JSON.parse(raw) + } catch { + return null + } +} + +function storeSession(roomCode: string, sessionId: string) { + sessionStorage.setItem(SESSION_KEY, JSON.stringify({ roomCode, sessionId })) +} + +export function useWebSocket(roomCode: string) { + const wsRef = useRef(null) + const { + setRoom, + setMySessionId, + setConnectionStatus, + updatePlayerConnected, + addPlayer, + setAct, + reset, + } = useRoomStore() + + const send = useCallback((message: ClientMessage) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)) + } + }, []) + + useEffect(() => { + const stored = getStoredSession() + const sessionId = stored?.roomCode === roomCode ? stored.sessionId : null + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const wsUrl = sessionId + ? `${protocol}//${window.location.host}/api/ws/${roomCode}?sessionId=${sessionId}` + : `${protocol}//${window.location.host}/api/ws/${roomCode}` + + setConnectionStatus("connecting") + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + setConnectionStatus("connected") + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) as ServerMessage + + switch (msg.type) { + case "room_state": { + setRoom(msg.room) + if (msg.sessionId) { + setMySessionId(msg.sessionId) + storeSession(roomCode, msg.sessionId) + } else if (sessionId) { + // Reconnected with stored session + setMySessionId(sessionId) + } + break + } + case "player_joined": + addPlayer(msg.player) + break + case "player_disconnected": + updatePlayerConnected(msg.playerId, false) + break + case "player_reconnected": + updatePlayerConnected(msg.playerId, true) + break + case "act_changed": + setAct(msg.newAct) + break + case "room_ended": + setAct("ended") + break + case "error": + console.error("Server error:", msg.message) + break + } + } + + ws.onclose = () => { + setConnectionStatus("disconnected") + } + + return () => { + ws.close() + reset() + } + }, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset]) + + return { send } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/stores/ packages/client/src/hooks/ +git commit -m "add Zustand room store, WebSocket connection hook" +``` + +**Note for Plan 5 (Polish):** The current WebSocket hook does not auto-reconnect on connection drops. For a party app where phones may lose Wi-Fi momentarily, add reconnection with exponential backoff in Plan 5. + +--- + +### Task 20: Landing page (create + join room) + +**Files:** +- Modify: `packages/client/src/routes/index.tsx` + +- [ ] **Step 1: Implement landing page** + +```tsx +import { useState } from "react" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useRoomStore } from "@/stores/room-store" + +export const Route = createFileRoute("/")({ + component: LandingPage, +}) + +function LandingPage() { + return ( +
+

ESC Party

+

Eurovision Song Contest — Party Companion

+
+ + +
+
+ ) +} + +function CreateRoomCard() { + const [displayName, setDisplayName] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const navigate = useNavigate() + const setMySessionId = useRoomStore((s) => s.setMySessionId) + + async function handleCreate() { + if (!displayName.trim()) return + setLoading(true) + setError("") + + try { + const res = await fetch("/api/rooms", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName: displayName.trim() }), + }) + const { data } = (await res.json()) as { data: { code: string; sessionId: string } } + + // Store session for reconnection + sessionStorage.setItem("esc-party-session", JSON.stringify({ + roomCode: data.code, + sessionId: data.sessionId, + })) + setMySessionId(data.sessionId) + + navigate({ to: "/host/$roomCode", params: { roomCode: data.code } }) + } catch { + setError("Failed to create room") + } finally { + setLoading(false) + } + } + + return ( + + + Host a Party + + + setDisplayName(e.target.value)} + maxLength={20} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + /> + + {error &&

{error}

} +
+
+ ) +} + +function JoinRoomCard() { + const [roomCode, setRoomCode] = useState("") + const [displayName, setDisplayName] = useState("") + const navigate = useNavigate() + + function handleJoin() { + if (!roomCode.trim() || !displayName.trim()) return + // Store display name temporarily — will be sent via WebSocket on connect + sessionStorage.setItem("esc-party-join-name", displayName.trim()) + navigate({ to: "/play/$roomCode", params: { roomCode: roomCode.trim().toUpperCase() } }) + } + + return ( + + + Join a Party + + + setRoomCode(e.target.value.toUpperCase())} + maxLength={4} + className="text-center text-2xl tracking-widest" + /> + setDisplayName(e.target.value)} + maxLength={20} + onKeyDown={(e) => e.key === "Enter" && handleJoin()} + /> + + + + ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/routes/index.tsx +git commit -m "implement landing page with create + join room forms" +``` + +--- + +### Task 21: Shared components + +**Files:** +- Create: `packages/client/src/components/player-list.tsx` +- Create: `packages/client/src/components/room-header.tsx` + +- [ ] **Step 1: Create player list component** + +```tsx +import { Badge } from "@/components/ui/badge" +import type { Player } from "@celebrate-esc/shared" + +interface PlayerListProps { + players: Player[] + mySessionId: string | null +} + +export function PlayerList({ players, mySessionId }: PlayerListProps) { + return ( +
+

+ Players ({players.length}) +

+
    + {players.map((player) => ( +
  • + + + {player.displayName} + + {player.isHost && ( + + Host + + )} + {player.sessionId === mySessionId && ( + (you) + )} +
  • + ))} +
+
+ ) +} +``` + +- [ ] **Step 2: Create room header component** + +```tsx +import { Badge } from "@/components/ui/badge" +import type { Act } from "@celebrate-esc/shared" + +interface RoomHeaderProps { + roomCode: string + currentAct: Act + connectionStatus: "disconnected" | "connecting" | "connected" +} + +const actLabels: Record = { + lobby: "Lobby", + act1: "Act 1", + act2: "Act 2", + act3: "Act 3", + ended: "Ended", +} + +export function RoomHeader({ roomCode, currentAct, connectionStatus }: RoomHeaderProps) { + return ( +
+
+ {roomCode} + {actLabels[currentAct]} +
+ +
+ ) +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/components/player-list.tsx packages/client/src/components/room-header.tsx +git commit -m "add player list, room header components" +``` + +--- + +### Task 22: Display view + +**Files:** +- Modify: `packages/client/src/routes/display.$roomCode.tsx` + +- [ ] **Step 1: Implement display view** + +The display shows the room code prominently (for joining), player list, and current act. During lobby, it shows a large room code + QR code placeholder. + +```tsx +import { createFileRoute } from "@tanstack/react-router" +import { useWebSocket } from "@/hooks/use-websocket" +import { useRoomStore } from "@/stores/room-store" +import { PlayerList } from "@/components/player-list" +import { RoomHeader } from "@/components/room-header" + +export const Route = createFileRoute("/display/$roomCode")({ + component: DisplayView, +}) + +function DisplayView() { + const { roomCode } = Route.useParams() + useWebSocket(roomCode) + const { room, connectionStatus } = useRoomStore() + + if (!room) { + return ( +
+

+ {connectionStatus === "connecting" ? "Connecting..." : "Room not found"} +

+
+ ) + } + + return ( +
+ +
+ {room.currentAct === "lobby" && } + +
+
+ ) +} + +function LobbyDisplay({ roomCode }: { roomCode: string }) { + const joinUrl = `${window.location.origin}/play/${roomCode}` + + return ( +
+

Join the party!

+
+ {roomCode} +
+

+ Go to {joinUrl} +

+

or scan the QR code

+ {/* QR code will be added in Plan 5 (polish) */} +
+ QR code +
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/routes/display.\$roomCode.tsx +git commit -m "implement display view with lobby screen, player list" +``` + +--- + +### Task 23: Host view + +**Files:** +- Modify: `packages/client/src/routes/host.$roomCode.tsx` + +- [ ] **Step 1: Implement host view** + +The host view has two tabs: Play (same as player view — games added later) and Host (control panel). For Plan 1, the Play tab shows the player list and the Host tab shows advance act + end room buttons. + +```tsx +import { createFileRoute } from "@tanstack/react-router" +import { useWebSocket } from "@/hooks/use-websocket" +import { useRoomStore } from "@/stores/room-store" +import { PlayerList } from "@/components/player-list" +import { RoomHeader } from "@/components/room-header" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import type { Act } from "@celebrate-esc/shared" + +export const Route = createFileRoute("/host/$roomCode")({ + component: HostView, +}) + +const nextActLabels: Partial> = { + lobby: "Start Act 1", + act1: "Start Act 2", + act2: "Start Act 3", + act3: "End Party", +} + +function HostView() { + const { roomCode } = Route.useParams() + const { send } = useWebSocket(roomCode) + const { room, mySessionId, connectionStatus } = useRoomStore() + + if (!room) { + return ( +
+

+ {connectionStatus === "connecting" ? "Connecting..." : "Room not found"} +

+
+ ) + } + + return ( +
+ + + + + Play + + + Host + + + + + {/* Game UI will be added in later plans */} + + +
+ + + Room Controls + + + {room.currentAct !== "ended" && ( + + )} + {room.currentAct !== "ended" && ( + + )} + {room.currentAct === "ended" && ( +

+ The party has ended. Thanks for playing! +

+ )} +
+
+ +
+
+
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/routes/host.\$roomCode.tsx +git commit -m "implement host view with play + host tabs, room controls" +``` + +--- + +### Task 24: Player view + +**Files:** +- Modify: `packages/client/src/routes/play.$roomCode.tsx` + +- [ ] **Step 1: Implement player view** + +The player connects to the WebSocket and sends `join_room` with the display name stored in sessionStorage from the landing page. Subsequent visits reconnect via session token. + +```tsx +import { useEffect, useRef, useState } from "react" +import { createFileRoute } from "@tanstack/react-router" +import { useWebSocket } from "@/hooks/use-websocket" +import { useRoomStore } from "@/stores/room-store" +import { PlayerList } from "@/components/player-list" +import { RoomHeader } from "@/components/room-header" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export const Route = createFileRoute("/play/$roomCode")({ + component: PlayerView, +}) + +function PlayerView() { + const { roomCode } = Route.useParams() + const { send } = useWebSocket(roomCode) + const { room, mySessionId, connectionStatus } = useRoomStore() + const joinSentRef = useRef(false) + const [manualName, setManualName] = useState("") + + // Auto-send join_room when connected for the first time (no existing session) + useEffect(() => { + if (connectionStatus !== "connected" || mySessionId || joinSentRef.current) return + + const displayName = sessionStorage.getItem("esc-party-join-name") + if (displayName) { + joinSentRef.current = true + sessionStorage.removeItem("esc-party-join-name") + send({ type: "join_room", displayName }) + } + }, [connectionStatus, mySessionId, send]) + + if (!room) { + return ( +
+

+ {connectionStatus === "connecting" ? "Connecting..." : "Room not found"} +

+
+ ) + } + + // Fallback: if no stored display name and no session (e.g., direct URL access), + // show a name input form + if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) { + return ( +
+

Join Room {roomCode}

+ setManualName(e.target.value)} + maxLength={20} + onKeyDown={(e) => { + if (e.key === "Enter" && manualName.trim()) { + joinSentRef.current = true + send({ type: "join_room", displayName: manualName.trim() }) + } + }} + /> + +
+ ) + } + + if (!mySessionId) { + return ( +
+

Joining room...

+
+ ) + } + + return ( +
+ +
+ {room.currentAct === "lobby" && ( +
+

Waiting for the host to start...

+
+ )} + {room.currentAct === "ended" && ( +
+

The party has ended. Thanks for playing!

+
+ )} + {/* Game UI will be added in later plans */} + +
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/routes/play.\$roomCode.tsx +git commit -m "implement player view with auto-join, reconnection support" +``` + +--- + +### Task 25: End-to-end manual smoke test + +- [ ] **Step 1: Start the server** + +```bash +cd packages/server +DATABASE_URL=postgresql://localhost:5433/celebrate_esc bun src/index.ts & +``` + +- [ ] **Step 2: Start the client** + +```bash +cd packages/client +bun dev & +``` + +- [ ] **Step 3: Test the full flow** + +1. Open `http://localhost:5173` in a browser +2. Enter a display name, click "Create Room" +3. Verify you're redirected to `/host/:roomCode` +4. Copy the room code +5. Open `http://localhost:5173/display/:roomCode` in another tab (simulating TV) +6. Verify the display shows the room code and the host in the player list +7. Open `http://localhost:5173` in an incognito window +8. Enter the room code + a display name, click "Join Room" +9. Verify the player appears in both the host and display views +10. On the host view, click "Start Act 1" +11. Verify all views update to show "Act 1" +12. Refresh the player's browser tab +13. Verify the player reconnects and sees the current state + +- [ ] **Step 4: Fix any issues found during smoke test** + +Address bugs, then commit fixes. + +- [ ] **Step 5: Run all tests** + +```bash +cd celebrate-esc # project root +bun test +``` + +- [ ] **Step 6: Run linter** + +```bash +biome check . +``` + +Fix any issues, then commit. + +- [ ] **Step 7: Final commit** + +```bash +git add -A +git commit -m "fix issues from smoke test, pass lint" +``` + +--- + +## Summary + +After completing this plan, you have: +- A working Bun workspace monorepo with shared, server, and client packages +- Shared Zod schemas for room state and WebSocket messages +- Hono server with WebSocket support and PostgreSQL persistence +- Room system: create, join, reconnect, advance acts, end room +- React client with TanStack Router, Zustand, and real-time WebSocket updates +- Four views: landing, display, host (with tabs), player +- Unit tests for room manager, integration tests for WebSocket handler + +**Next plan:** Act 1 Games (Predictions + Dish of the Nation) — adds the first game logic on top of this foundation. diff --git a/docs/superpowers/plans/2026-03-12-act1-predictions-dishes.md b/docs/superpowers/plans/2026-03-12-act1-predictions-dishes.md new file mode 100644 index 0000000..1dd89e9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-act1-predictions-dishes.md @@ -0,0 +1,2095 @@ +# Act 1 Games — Predictions + Dishes + +> **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:** Implement the two Act 1 games — Prediction Voting and Dish of the Nation — including server game logic, WS message handling, client UI for all three views (host, player, display), and in-memory + DB persistence. + +**Architecture:** Follows the existing room system pattern. Server keeps game state in-memory (extending `InternalRoom`), persists to PostgreSQL via service classes. All mutations flow through WebSocket messages. Client receives state slices via new message types and renders game UI. DB schema tables already exist from Plan 1 migrations (`predictions`, `dishes`, `dish_guesses`). + +**Tech Stack:** Same as Plan 1 — Bun, Hono + @hono/node-ws, Drizzle + PostgreSQL, React 19 + Vite + Tailwind v4 + shadcn/ui, TanStack Router, Zustand, Zod, Biome, Vitest + +--- + +## Plan Series + +This is **Plan 2 of 5:** +1. Foundation + Room System (done) +2. **Act 1 Games (Predictions + Dishes)** ← this plan +3. Act 2 Games (Jury Voting + Bingo) +4. Act 3 Games (Quiz + Final Leaderboard) +5. Polish + Deployment + +--- + +## Design Decisions + +1. **ESC 2026 lineup data** — create `packages/server/data/esc-2026.json` with the participating countries. Client receives the lineup as part of game state so it can render country selectors. +2. **In-memory game state** — extend `InternalRoom` in `room-manager.ts` with predictions, dishes, and dish guesses. Keep the same pattern: in-memory is the source of truth, DB persistence is fire-and-forget. +3. **Game state broadcast** — when predictions or dishes change, broadcast the relevant state slice to the room. Players only see their own prediction (not others'). Everyone sees the dish list. +4. **Predictions lock** — predictions are available from lobby through Act 1. They lock when the host advances to Act 2 (the existing `advance_act` handler just needs to broadcast `predictions_locked`). +5. **Dish reveal** — host triggers reveal, which flips `revealed: true` on all dishes and broadcasts results with correct answers and who guessed right. +6. **Country type** — use country codes (e.g., `"SE"`, `"DE"`) as identifiers. The lineup JSON maps codes to display names. + +--- + +## File Structure + +``` +packages/ +├── shared/src/ +│ ├── constants.ts # (modify) add ESC_YEAR +│ ├── game-types.ts # (create) prediction, dish, lineup types +│ ├── ws-messages.ts # (modify) add game messages to unions +│ └── index.ts # (modify) re-export game-types +├── server/ +│ ├── data/ +│ │ └── esc-2026.json # (create) country lineup +│ └── src/ +│ ├── games/ +│ │ ├── game-manager.ts # (create) in-memory game state per room +│ │ └── game-service.ts # (create) DB persistence for games +│ ├── ws/ +│ │ └── handler.ts # (modify) add game message cases +│ └── rooms/ +│ └── room-manager.ts # (modify) expose game manager per room +├── client/src/ +│ ├── stores/ +│ │ └── room-store.ts # (modify) add game state + actions +│ ├── hooks/ +│ │ └── use-websocket.ts # (modify) handle game messages +│ ├── components/ +│ │ ├── predictions-form.tsx # (create) prediction submission UI +│ │ ├── dish-list.tsx # (create) dish list + guess UI +│ │ ├── dish-host.tsx # (create) host: add dishes + reveal +│ │ └── dish-results.tsx # (create) revealed dish results +│ └── routes/ +│ ├── host.$roomCode.tsx # (modify) add game UI in Play tab, dish controls in Host tab +│ ├── play.$roomCode.tsx # (modify) add game UI for act1 +│ └── display.$roomCode.tsx # (modify) show dishes/predictions status on display +``` + +--- + +## Chunk 1: Data + Shared Types + +### Task 1: ESC 2026 lineup data file + +**Files:** +- Create: `packages/server/data/esc-2026.json` + +- [ ] **Step 1: Create the lineup JSON** + +```json +{ + "year": 2026, + "countries": [ + { "code": "AL", "name": "Albania" }, + { "code": "AM", "name": "Armenia" }, + { "code": "AU", "name": "Australia" }, + { "code": "AT", "name": "Austria" }, + { "code": "AZ", "name": "Azerbaijan" }, + { "code": "BE", "name": "Belgium" }, + { "code": "HR", "name": "Croatia" }, + { "code": "CY", "name": "Cyprus" }, + { "code": "CZ", "name": "Czechia" }, + { "code": "DK", "name": "Denmark" }, + { "code": "EE", "name": "Estonia" }, + { "code": "FI", "name": "Finland" }, + { "code": "FR", "name": "France" }, + { "code": "DE", "name": "Germany" }, + { "code": "GE", "name": "Georgia" }, + { "code": "GR", "name": "Greece" }, + { "code": "IS", "name": "Iceland" }, + { "code": "IE", "name": "Ireland" }, + { "code": "IL", "name": "Israel" }, + { "code": "IT", "name": "Italy" }, + { "code": "LV", "name": "Latvia" }, + { "code": "LT", "name": "Lithuania" }, + { "code": "LU", "name": "Luxembourg" }, + { "code": "MT", "name": "Malta" }, + { "code": "MD", "name": "Moldova" }, + { "code": "ME", "name": "Montenegro" }, + { "code": "NL", "name": "Netherlands" }, + { "code": "MK", "name": "North Macedonia" }, + { "code": "NO", "name": "Norway" }, + { "code": "PL", "name": "Poland" }, + { "code": "PT", "name": "Portugal" }, + { "code": "RO", "name": "Romania" }, + { "code": "SM", "name": "San Marino" }, + { "code": "RS", "name": "Serbia" }, + { "code": "SI", "name": "Slovenia" }, + { "code": "ES", "name": "Spain" }, + { "code": "SE", "name": "Sweden" }, + { "code": "CH", "name": "Switzerland" }, + { "code": "UA", "name": "Ukraine" }, + { "code": "GB", "name": "United Kingdom" } + ] +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/server/data/esc-2026.json +git commit -m "add ESC 2026 country lineup data" +``` + +--- + +### Task 2: Shared game types + +**Files:** +- Create: `packages/shared/src/game-types.ts` +- Modify: `packages/shared/src/index.ts` + +- [ ] **Step 1: Create game types** + +Create `packages/shared/src/game-types.ts`: + +```typescript +import { z } from "zod" + +// ─── Country Lineup ───────────────────────────────────────────────── + +export const countrySchema = z.object({ + code: z.string(), + name: z.string(), +}) + +export type Country = z.infer + +export const lineupSchema = z.object({ + year: z.number(), + countries: z.array(countrySchema), +}) + +export type Lineup = z.infer + +// ─── Predictions ──────────────────────────────────────────────────── + +export const predictionSchema = z.object({ + playerId: z.string().uuid(), + predictedWinner: z.string(), + top3: z.array(z.string()).length(3), + nulPointsPick: z.string(), +}) + +export type Prediction = z.infer + +// ─── Dishes ───────────────────────────────────────────────────────── + +export const dishSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + correctCountry: z.string(), + revealed: z.boolean(), +}) + +export type Dish = z.infer + +export const dishGuessSchema = z.object({ + dishId: z.string().uuid(), + playerId: z.string().uuid(), + guessedCountry: z.string(), +}) + +export type DishGuess = z.infer + +// ─── Game State (sent to clients) ─────────────────────────────────── + +/** State slice sent to each player — they only see their own prediction */ +export const gameStateSchema = z.object({ + lineup: lineupSchema, + myPrediction: predictionSchema.nullable(), + predictionsLocked: z.boolean(), + dishes: z.array(dishSchema), + myDishGuesses: z.array(dishGuessSchema), + dishResults: z + .array( + z.object({ + dish: dishSchema, + guesses: z.array( + z.object({ + playerId: z.string().uuid(), + displayName: z.string(), + guessedCountry: z.string(), + correct: z.boolean(), + }), + ), + }), + ) + .nullable(), +}) + +export type GameState = z.infer +``` + +- [ ] **Step 2: Add re-export to index.ts** + +Add to `packages/shared/src/index.ts`: + +```typescript +export * from "./game-types" +``` + +- [ ] **Step 3: Verify types compile** + +Run: `cd packages/shared && bun run tsc --noEmit` +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add packages/shared/src/game-types.ts packages/shared/src/index.ts +git commit -m "add shared game types for predictions, dishes" +``` + +--- + +### Task 3: WebSocket message types for games + +**Files:** +- Modify: `packages/shared/src/ws-messages.ts` + +- [ ] **Step 1: Add client → server game messages** + +Add after the `endRoomMessage` definition (before the `clientMessage` union) in `packages/shared/src/ws-messages.ts`: + +```typescript +// ─── Client → Server (Act 1 games) ───────────────────────────────── + +export const submitPredictionMessage = z.object({ + type: z.literal("submit_prediction"), + predictedWinner: z.string(), + top3: z.array(z.string()).length(3), + nulPointsPick: z.string(), +}) + +export const addDishMessage = z.object({ + type: z.literal("add_dish"), + name: z.string().min(1).max(100), + correctCountry: z.string(), +}) + +export const submitDishGuessMessage = z.object({ + type: z.literal("submit_dish_guess"), + dishId: z.string().uuid(), + guessedCountry: z.string(), +}) + +export const revealDishesMessage = z.object({ + type: z.literal("reveal_dishes"), +}) +``` + +- [ ] **Step 2: Update the clientMessage union** + +Replace the existing `clientMessage` union to include the new message types: + +```typescript +export const clientMessage = z.discriminatedUnion("type", [ + joinRoomMessage, + reconnectMessage, + advanceActMessage, + endRoomMessage, + submitPredictionMessage, + addDishMessage, + submitDishGuessMessage, + revealDishesMessage, +]) +``` + +- [ ] **Step 3: Add server → client game messages** + +Add `import { gameStateSchema, dishSchema } from "./game-types"` at the **top** of `ws-messages.ts`, alongside the existing imports. + +Then add after the `errorMessage` definition (before the `serverMessage` union): + +```typescript +// ─── Server → Client (Act 1 games) ───────────────────────────────── + +export const gameStateMessage = z.object({ + type: z.literal("game_state"), + gameState: gameStateSchema, +}) + +export const predictionsLockedMessage = z.object({ + type: z.literal("predictions_locked"), +}) + +export const dishAddedMessage = z.object({ + type: z.literal("dish_added"), + dish: dishSchema, +}) + +export const dishGuessRecordedMessage = z.object({ + type: z.literal("dish_guess_recorded"), + dishId: z.string().uuid(), + guessedCountry: z.string(), +}) + +export const dishesRevealedMessage = z.object({ + type: z.literal("dishes_revealed"), + results: z.array( + z.object({ + dish: dishSchema, + guesses: z.array( + z.object({ + playerId: z.string().uuid(), + displayName: z.string(), + guessedCountry: z.string(), + correct: z.boolean(), + }), + ), + }), + ), +}) +``` + +- [ ] **Step 4: Update the serverMessage union** + +Replace the existing `serverMessage` union to include the new message types: + +```typescript +export const serverMessage = z.discriminatedUnion("type", [ + roomStateMessage, + playerJoinedMessage, + playerDisconnectedMessage, + playerReconnectedMessage, + actChangedMessage, + roomEndedMessage, + errorMessage, + gameStateMessage, + predictionsLockedMessage, + dishAddedMessage, + dishGuessRecordedMessage, + dishesRevealedMessage, +]) +``` + +- [ ] **Step 5: Verify types compile** + +Run: `cd packages/shared && bun run tsc --noEmit` +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add packages/shared/src/ws-messages.ts +git commit -m "add WS message types for predictions, dishes" +``` + +--- + +## Chunk 2: Server Game Logic + +### Task 4: Game manager (in-memory state) + +**Files:** +- Create: `packages/server/src/games/game-manager.ts` + +- [ ] **Step 1: Create the game manager** + +Create `packages/server/src/games/game-manager.ts`: + +```typescript +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() // 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 { + 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, + ): { 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): 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, + } + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/server && bun run build` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/src/games/game-manager.ts +git commit -m "add game manager for predictions, dishes in-memory state" +``` + +--- + +### Task 5: Wire game manager into room manager + +**Files:** +- Modify: `packages/server/src/rooms/room-manager.ts` + +- [ ] **Step 1: Add GameManager to InternalRoom** + +Add import at top of `packages/server/src/rooms/room-manager.ts`: + +```typescript +import { GameManager } from "../games/game-manager" +``` + +Add to `InternalRoom` interface: + +```typescript +interface InternalRoom { + // ...existing fields... + gameManager: GameManager +} +``` + +- [ ] **Step 2: Initialize GameManager in createRoom** + +In the `createRoom` method, add `gameManager: new GameManager()` to the room object: + +```typescript +const room: InternalRoom = { + id: randomUUID(), + code, + currentAct: "lobby", + hostSessionId: sessionId, + players: new Map([[sessionId, host]]), + createdAt: now, + expiresAt: new Date(now.getTime() + ROOM_EXPIRY_HOURS * 60 * 60 * 1000), + gameManager: new GameManager(), +} +``` + +- [ ] **Step 3: Add getGameManager method** + +Add a method to `RoomManager`: + +```typescript +getGameManager(code: string): GameManager | null { + const room = this.rooms.get(code) + return room?.gameManager ?? null +} +``` + +- [ ] **Step 4: Add getPlayerLookup method** + +Add a helper to get a playerId → displayName map: + +```typescript +getPlayerLookup(code: string): Map { + const room = this.rooms.get(code) + if (!room) return new Map() + const lookup = new Map() + for (const player of room.players.values()) { + lookup.set(player.id, player.displayName) + } + return lookup +} +``` + +- [ ] **Step 5: Add getPlayerIdBySession method** + +Add a helper to resolve sessionId → playerId: + +```typescript +getPlayerIdBySession(code: string, sessionId: string): string | null { + const room = this.rooms.get(code) + if (!room) return null + return room.players.get(sessionId)?.id ?? null +} +``` + +- [ ] **Step 6: Verify it compiles** + +Run: `cd packages/server && bun run build` +Expected: no errors + +- [ ] **Step 7: Commit** + +```bash +git add packages/server/src/rooms/room-manager.ts +git commit -m "wire game manager into room manager" +``` + +--- + +### Task 6: Game service (DB persistence) + +**Files:** +- Create: `packages/server/src/games/game-service.ts` + +- [ ] **Step 1: Create the game service** + +Create `packages/server/src/games/game-service.ts`: + +Note: DB persistence is fire-and-forget — the in-memory `GameManager` is the source of truth. Persist calls are added to the WS handler in Task 7 but failures are logged, not propagated to clients. The `predictions` table has no unique constraint on `(player_id, room_id)`, so we use delete-then-insert for upserts. Same for `dish_guesses` on `(player_id, dish_id)`. + +```typescript +import { eq, and } from "drizzle-orm" +import type { Database } from "../db/client" +import { predictions, dishes, dishGuesses } from "../db/schema" + +export class GameService { + constructor(private db: Database) {} + + async persistPrediction(data: { + playerId: string + roomId: string + predictedWinner: string + top3: string[] + nulPointsPick: string + }) { + // Delete existing prediction for this player+room, then insert + await this.db + .delete(predictions) + .where(and(eq(predictions.playerId, data.playerId), eq(predictions.roomId, data.roomId))) + await this.db.insert(predictions).values({ + playerId: data.playerId, + roomId: data.roomId, + predictedWinner: data.predictedWinner, + top3: data.top3, + nulPointsPick: data.nulPointsPick, + }) + } + + async persistDish(data: { + id: string + roomId: string + name: string + correctCountry: string + }) { + await this.db.insert(dishes).values({ + id: data.id, + roomId: data.roomId, + name: data.name, + correctCountry: data.correctCountry, + }) + } + + async persistDishGuess(data: { + playerId: string + dishId: string + guessedCountry: string + }) { + // Delete existing guess for this player+dish, then insert + await this.db + .delete(dishGuesses) + .where(and(eq(dishGuesses.playerId, data.playerId), eq(dishGuesses.dishId, data.dishId))) + await this.db.insert(dishGuesses).values({ + playerId: data.playerId, + dishId: data.dishId, + guessedCountry: data.guessedCountry, + }) + } + + async markDishesRevealed(roomId: string) { + await this.db + .update(dishes) + .set({ revealed: true }) + .where(eq(dishes.roomId, roomId)) + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/server && bun run build` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/src/games/game-service.ts +git commit -m "add game service for DB persistence of predictions, dishes" +``` + +--- + +### Task 7: WebSocket handler — game message routing + +**Files:** +- Modify: `packages/server/src/ws/handler.ts` + +- [ ] **Step 1: Add game state helper function** + +Add a helper function in `handler.ts` (before `registerWebSocketRoutes`) that sends game state to a specific player: + +```typescript +function sendGameState(ws: WSContext, roomCode: string, sessionId: string) { + const gm = roomManager.getGameManager(roomCode) + const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId) + if (!gm || !playerId) return + + const playerLookup = roomManager.getPlayerLookup(roomCode) + const gameState = gm.getGameStateForPlayer(playerId, playerLookup) + sendTo(ws, { type: "game_state", gameState }) +} +``` + +- [ ] **Step 2: Send game state on join and reconnect** + +In the `onOpen` handler, after sending `room_state` on successful reconnect (the `sendTo(ws, { type: "room_state", ... })` call), add: + +```typescript +sendGameState(ws, roomCode, sessionId) +``` + +In the `join_room` case, after `sendTo(ws, { type: "room_state", ... })`, add: + +```typescript +sendGameState(ws, roomCode, result.sessionId) +``` + +In the `reconnect` case, after `sendTo(ws, { type: "room_state", ... })`, add: + +```typescript +sendGameState(ws, roomCode, msg.sessionId) +``` + +- [ ] **Step 3: Lock predictions on act advance to act2** + +In the `advance_act` case, after the broadcast of `act_changed`, add: + +```typescript +if (result.newAct === "act2") { + const gm = roomManager.getGameManager(roomCode) + if (gm) { + gm.lockPredictions() + broadcast(roomCode, { type: "predictions_locked" }) + } +} +``` + +- [ ] **Step 4: Add submit_prediction handler** + +Add a new case in the message switch: + +```typescript +case "submit_prediction": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId) + const gm = roomManager.getGameManager(roomCode) + if (!playerId || !gm) { + sendError(ws, "Room not found") + return + } + + const result = gm.submitPrediction(playerId, msg.predictedWinner, msg.top3, msg.nulPointsPick) + if ("error" in result) { + sendError(ws, result.error) + return + } + + // Send updated game state back to this player only + sendGameState(ws, roomCode, sessionId) + break +} +``` + +- [ ] **Step 5: Add add_dish handler (host only)** + +```typescript +case "add_dish": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + if (!roomManager.isHost(roomCode, sessionId)) { + sendError(ws, "Only the host can add dishes") + return + } + const room = roomManager.getRoom(roomCode) + if (room && room.currentAct !== "lobby" && room.currentAct !== "act1") { + sendError(ws, "Dishes can only be added during lobby or Act 1") + return + } + const gm = roomManager.getGameManager(roomCode) + if (!gm) { + sendError(ws, "Room not found") + return + } + + const result = gm.addDish(msg.name, msg.correctCountry) + if ("error" in result) { + sendError(ws, result.error) + return + } + + broadcast(roomCode, { + type: "dish_added", + dish: { + id: result.dish.id, + name: result.dish.name, + correctCountry: "", + revealed: false, + }, + }) + break +} +``` + +- [ ] **Step 6: Add submit_dish_guess handler** + +```typescript +case "submit_dish_guess": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId) + const gm = roomManager.getGameManager(roomCode) + if (!playerId || !gm) { + sendError(ws, "Room not found") + return + } + + const result = gm.submitDishGuess(playerId, msg.dishId, msg.guessedCountry) + if ("error" in result) { + sendError(ws, result.error) + return + } + + sendTo(ws, { + type: "dish_guess_recorded", + dishId: msg.dishId, + guessedCountry: msg.guessedCountry, + }) + break +} +``` + +- [ ] **Step 7: Add reveal_dishes handler (host only)** + +```typescript +case "reveal_dishes": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + if (!roomManager.isHost(roomCode, sessionId)) { + sendError(ws, "Only the host can reveal dishes") + return + } + const gm = roomManager.getGameManager(roomCode) + if (!gm) { + sendError(ws, "Room not found") + return + } + + gm.revealDishes() + const playerLookup = roomManager.getPlayerLookup(roomCode) + const results = gm.getDishResults(playerLookup) + + broadcast(roomCode, { + type: "dishes_revealed", + results, + }) + break +} +``` + +- [ ] **Step 8: Send display-safe game state to passive viewers** + +The display view has no `sessionId`/`playerId`, so it cannot receive a player-specific `game_state`. Add a `getGameStateForDisplay()` method to `GameManager` and send it on passive connections. + +In `packages/server/src/games/game-manager.ts`, add: + +```typescript +getGameStateForDisplay(playerLookup: Map): 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, + } +} +``` + +Then in `handler.ts`, add a helper: + +```typescript +function sendDisplayGameState(ws: WSContext, roomCode: string) { + const gm = roomManager.getGameManager(roomCode) + if (!gm) return + + const playerLookup = roomManager.getPlayerLookup(roomCode) + const gameState = gm.getGameStateForDisplay(playerLookup) + sendTo(ws, { type: "game_state", gameState }) +} +``` + +In the `onOpen` handler's `else` branch (passive viewer), after sending `room_state`, add: + +```typescript +sendDisplayGameState(ws, roomCode) +``` + +This ensures the display starts with a valid `gameState` (non-null), so subsequent `dish_added` and `dishes_revealed` broadcasts update correctly through the Zustand store. + +- [ ] **Step 9: Verify server compiles** + +Run: `cd packages/server && bun run build` +Expected: no errors + +- [ ] **Step 10: Commit** + +```bash +git add packages/server/src/ws/handler.ts +git commit -m "add WS handlers for predictions, dishes game messages" +``` + +--- + +### Task 8: Game manager unit tests + +**Files:** +- Create: `packages/server/tests/game-manager.test.ts` + +- [ ] **Step 1: Write tests** + +Create `packages/server/tests/game-manager.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from "vitest" +import { GameManager } from "../src/games/game-manager" + +describe("GameManager", () => { + let gm: GameManager + + beforeEach(() => { + gm = new GameManager() + }) + + describe("lineup", () => { + it("returns the ESC 2026 lineup", () => { + const lineup = gm.getLineup() + expect(lineup.year).toBe(2026) + expect(lineup.countries.length).toBeGreaterThan(20) + expect(lineup.countries[0]).toHaveProperty("code") + expect(lineup.countries[0]).toHaveProperty("name") + }) + + it("validates country codes", () => { + expect(gm.isValidCountry("DE")).toBe(true) + expect(gm.isValidCountry("XX")).toBe(false) + }) + }) + + describe("predictions", () => { + it("accepts a valid prediction", () => { + const result = gm.submitPrediction("p1", "SE", ["DE", "IT", "FR"], "GB") + expect(result).toEqual({ success: true }) + expect(gm.getPrediction("p1")).toEqual({ + playerId: "p1", + predictedWinner: "SE", + top3: ["DE", "IT", "FR"], + nulPointsPick: "GB", + }) + }) + + it("rejects prediction with invalid country", () => { + const result = gm.submitPrediction("p1", "XX", ["DE", "IT", "FR"], "GB") + expect(result).toEqual({ error: "Invalid country: XX" }) + }) + + it("rejects winner in top 3", () => { + const result = gm.submitPrediction("p1", "SE", ["SE", "IT", "FR"], "GB") + expect(result).toEqual({ error: "Winner cannot also be in top 3" }) + }) + + it("rejects duplicate top 3", () => { + const result = gm.submitPrediction("p1", "SE", ["DE", "DE", "FR"], "GB") + expect(result).toEqual({ error: "Top 3 must be unique countries" }) + }) + + it("allows overwriting a prediction", () => { + gm.submitPrediction("p1", "SE", ["DE", "IT", "FR"], "GB") + gm.submitPrediction("p1", "NO", ["DE", "IT", "FR"], "GB") + expect(gm.getPrediction("p1")?.predictedWinner).toBe("NO") + }) + + it("rejects prediction when locked", () => { + gm.lockPredictions() + const result = gm.submitPrediction("p1", "SE", ["DE", "IT", "FR"], "GB") + expect(result).toEqual({ error: "Predictions are locked" }) + }) + }) + + describe("dishes", () => { + it("adds a dish", () => { + const result = gm.addDish("Köttbullar", "SE") + expect("dish" in result).toBe(true) + if ("dish" in result) { + expect(result.dish.name).toBe("Köttbullar") + expect(result.dish.correctCountry).toBe("SE") + expect(result.dish.revealed).toBe(false) + } + }) + + it("rejects dish with invalid country", () => { + const result = gm.addDish("Mystery", "XX") + expect(result).toEqual({ error: "Invalid country: XX" }) + }) + + it("accepts a dish guess", () => { + const addResult = gm.addDish("Köttbullar", "SE") + if (!("dish" in addResult)) throw new Error("unexpected") + const result = gm.submitDishGuess("p1", addResult.dish.id, "SE") + expect(result).toEqual({ success: true }) + }) + + it("rejects guess for nonexistent dish", () => { + const result = gm.submitDishGuess("p1", "fake-id", "SE") + expect(result).toEqual({ error: "Dish not found" }) + }) + + it("rejects guess after reveal", () => { + const addResult = gm.addDish("Köttbullar", "SE") + if (!("dish" in addResult)) throw new Error("unexpected") + gm.revealDishes() + const result = gm.submitDishGuess("p1", addResult.dish.id, "SE") + expect(result).toEqual({ error: "Dish already revealed" }) + }) + + it("replaces an existing guess for the same dish", () => { + const addResult = gm.addDish("Köttbullar", "SE") + if (!("dish" in addResult)) throw new Error("unexpected") + gm.submitDishGuess("p1", addResult.dish.id, "DE") + gm.submitDishGuess("p1", addResult.dish.id, "SE") + const guesses = gm.getDishGuesses("p1") + expect(guesses).toHaveLength(1) + expect(guesses[0]?.guessedCountry).toBe("SE") + }) + + it("reveals dishes and produces results", () => { + const addResult = gm.addDish("Köttbullar", "SE") + if (!("dish" in addResult)) throw new Error("unexpected") + gm.submitDishGuess("p1", addResult.dish.id, "SE") + gm.submitDishGuess("p2", addResult.dish.id, "DE") + gm.revealDishes() + + const lookup = new Map([ + ["p1", "Alice"], + ["p2", "Bob"], + ]) + const results = gm.getDishResults(lookup) + expect(results).toHaveLength(1) + expect(results[0]?.guesses).toHaveLength(2) + + const aliceGuess = results[0]?.guesses.find((g) => g.playerId === "p1") + expect(aliceGuess?.correct).toBe(true) + + const bobGuess = results[0]?.guesses.find((g) => g.playerId === "p2") + expect(bobGuess?.correct).toBe(false) + }) + }) + + describe("getGameStateForPlayer", () => { + it("hides correct country for unrevealed dishes", () => { + gm.addDish("Köttbullar", "SE") + const lookup = new Map() + const state = gm.getGameStateForPlayer("p1", lookup) + expect(state.dishes[0]?.correctCountry).toBe("") + }) + + it("shows correct country after reveal", () => { + gm.addDish("Köttbullar", "SE") + gm.revealDishes() + const lookup = new Map() + const state = gm.getGameStateForPlayer("p1", lookup) + expect(state.dishes[0]?.correctCountry).toBe("SE") + }) + + it("includes only the requesting player's prediction", () => { + gm.submitPrediction("p1", "SE", ["DE", "IT", "FR"], "GB") + gm.submitPrediction("p2", "NO", ["DE", "IT", "FR"], "GB") + const lookup = new Map() + const state = gm.getGameStateForPlayer("p1", lookup) + expect(state.myPrediction?.predictedWinner).toBe("SE") + }) + }) +}) +``` + +- [ ] **Step 2: Run tests** + +Run: `cd packages/server && bun run test` +Expected: all tests pass + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/tests/game-manager.test.ts +git commit -m "add game manager unit tests" +``` + +--- + +## Chunk 3: Client State + Hooks + +### Task 9: Update Zustand store with game state + +**Files:** +- Modify: `packages/client/src/stores/room-store.ts` + +- [ ] **Step 1: Add game state to the store** + +Import types at top: + +```typescript +import type { GameState, Dish, DishGuess } from "@celebrate-esc/shared" +``` + +Add to the store interface: + +```typescript +gameState: GameState | null +setGameState: (gameState: GameState) => void +addDish: (dish: Dish) => void +recordDishGuess: (dishId: string, guessedCountry: string) => void +lockPredictions: () => void +setDishResults: (results: GameState["dishResults"]) => void +``` + +Add implementations: + +```typescript +gameState: null, +setGameState: (gameState) => set({ gameState }), +addDish: (dish) => + set((state) => { + if (!state.gameState) return state + return { + gameState: { + ...state.gameState, + dishes: [...state.gameState.dishes, dish], + }, + } + }), +recordDishGuess: (dishId, guessedCountry) => + set((state) => { + if (!state.gameState) return state + const existing = state.gameState.myDishGuesses.filter((g) => g.dishId !== dishId) + return { + gameState: { + ...state.gameState, + myDishGuesses: [...existing, { dishId, playerId: "", guessedCountry }], + }, + } + }), +lockPredictions: () => + set((state) => { + if (!state.gameState) return state + return { + gameState: { ...state.gameState, predictionsLocked: true }, + } + }), +setDishResults: (results) => + set((state) => { + if (!state.gameState) return state + return { + gameState: { + ...state.gameState, + dishResults: results, + dishes: state.gameState.dishes.map((d) => ({ ...d, revealed: true })), + }, + } + }), +``` + +Also update `reset` to clear game state: + +```typescript +reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null }), +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/client && bun run build` +Expected: no errors (or only type-check warnings from unused imports, which is fine at this stage) + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/stores/room-store.ts +git commit -m "add game state to zustand store" +``` + +--- + +### Task 10: Handle game messages in WebSocket hook + +**Files:** +- Modify: `packages/client/src/hooks/use-websocket.ts` + +- [ ] **Step 1: Add game state handlers to the store destructure** + +Update the destructured values from `useRoomStore()`: + +```typescript +const { + setRoom, + setMySessionId, + setConnectionStatus, + updatePlayerConnected, + addPlayer, + setAct, + reset, + setGameState, + addDish, + recordDishGuess, + lockPredictions, + setDishResults, +} = useRoomStore() +``` + +- [ ] **Step 2: Add message handlers in the switch** + +Add cases in the `ws.onmessage` switch statement: + +```typescript +case "game_state": + setGameState(msg.gameState) + break +case "predictions_locked": + lockPredictions() + break +case "dish_added": + addDish(msg.dish) + break +case "dish_guess_recorded": + recordDishGuess(msg.dishId, msg.guessedCountry) + break +case "dishes_revealed": + setDishResults(msg.results) + break +``` + +- [ ] **Step 3: Update the useEffect dependency array** + +Add the new store actions to the dependency array: + +```typescript +}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset, setGameState, addDish, recordDishGuess, lockPredictions, setDishResults]) +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cd packages/client && bun run build` +Expected: no errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/client/src/hooks/use-websocket.ts +git commit -m "handle game WS messages in client hook" +``` + +--- + +## Chunk 4: Client UI Components + +### Task 11: Prediction form component + +**Files:** +- Create: `packages/client/src/components/predictions-form.tsx` + +- [ ] **Step 1: Create the predictions form** + +Create `packages/client/src/components/predictions-form.tsx`: + +```tsx +import { useState } from "react" +import type { Country, Prediction } from "@celebrate-esc/shared" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +interface PredictionsFormProps { + countries: Country[] + existingPrediction: Prediction | null + locked: boolean + onSubmit: (prediction: { predictedWinner: string; top3: string[]; nulPointsPick: string }) => void +} + +export function PredictionsForm({ countries, existingPrediction, locked, onSubmit }: PredictionsFormProps) { + const [winner, setWinner] = useState(existingPrediction?.predictedWinner ?? "") + const [top3, setTop3] = useState(existingPrediction?.top3 ?? []) + const [nulPoints, setNulPoints] = useState(existingPrediction?.nulPointsPick ?? "") + + if (locked) { + if (!existingPrediction) { + return ( + + + Predictions are locked. You didn't submit one in time. + + + ) + } + return ( + + + Your Predictions (locked) + + +

+ Winner:{" "} + {countries.find((c) => c.code === existingPrediction.predictedWinner)?.name} +

+

+ Top 3:{" "} + {existingPrediction.top3.map((code) => countries.find((c) => c.code === code)?.name).join(", ")} +

+

+ Nul Points:{" "} + {countries.find((c) => c.code === existingPrediction.nulPointsPick)?.name} +

+
+
+ ) + } + + function toggleTop3(code: string) { + setTop3((prev) => { + if (prev.includes(code)) return prev.filter((c) => c !== code) + if (prev.length >= 3) return prev + return [...prev, code] + }) + } + + const canSubmit = winner && top3.length === 3 && nulPoints && !top3.includes(winner) + + return ( + + + Predictions + + +
+ + +
+ +
+ +
+ {countries + .filter((c) => c.code !== winner) + .map((c) => ( + + ))} +
+
+ +
+ + +
+ + +
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/components/predictions-form.tsx +git commit -m "add predictions form component" +``` + +--- + +### Task 12: Dish components + +**Files:** +- Create: `packages/client/src/components/dish-list.tsx` +- Create: `packages/client/src/components/dish-host.tsx` +- Create: `packages/client/src/components/dish-results.tsx` + +- [ ] **Step 1: Create dish list (player view)** + +Create `packages/client/src/components/dish-list.tsx`: + +```tsx +import { useState } from "react" +import type { Country, Dish, DishGuess } from "@celebrate-esc/shared" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +interface DishListProps { + dishes: Dish[] + myGuesses: DishGuess[] + countries: Country[] + onGuess: (dishId: string, guessedCountry: string) => void +} + +export function DishList({ dishes, myGuesses, countries, onGuess }: DishListProps) { + if (dishes.length === 0) { + return ( + + + No dishes yet — the host will add them. + + + ) + } + + return ( + + + Dish of the Nation + + + {dishes.map((dish) => ( + g.dishId === dish.id)} + countries={countries} + onGuess={onGuess} + /> + ))} + + + ) +} + +function DishItem({ + dish, + myGuess, + countries, + onGuess, +}: { + dish: Dish + myGuess: DishGuess | undefined + countries: Country[] + onGuess: (dishId: string, guessedCountry: string) => void +}) { + const [selected, setSelected] = useState(myGuess?.guessedCountry ?? "") + + if (dish.revealed) { + return ( +
+

{dish.name}

+

+ Answer: {countries.find((c) => c.code === dish.correctCountry)?.name} +

+ {myGuess && ( +

+ Your guess: {countries.find((c) => c.code === myGuess.guessedCountry)?.name} + {myGuess.guessedCountry === dish.correctCountry ? " ✓" : " ✗"} +

+ )} +
+ ) + } + + return ( +
+

{dish.name}

+
+ +
+ {myGuess && ( +

+ Guessed: {countries.find((c) => c.code === myGuess.guessedCountry)?.name} +

+ )} +
+ ) +} +``` + +- [ ] **Step 2: Create dish host controls** + +Create `packages/client/src/components/dish-host.tsx`: + +```tsx +import { useState } from "react" +import type { Country, Dish } from "@celebrate-esc/shared" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +interface DishHostProps { + dishes: Dish[] + countries: Country[] + onAddDish: (name: string, correctCountry: string) => void + onReveal: () => void +} + +export function DishHost({ dishes, countries, onAddDish, onReveal }: DishHostProps) { + const [name, setName] = useState("") + const [country, setCountry] = useState("") + const allRevealed = dishes.length > 0 && dishes.every((d) => d.revealed) + + return ( + + + Dish of the Nation + + + {!allRevealed && ( +
+ setName(e.target.value)} maxLength={100} /> + + +
+ )} + + {dishes.length > 0 && ( +
+

{dishes.length} dish(es) added:

+
    + {dishes.map((d) => ( +
  • + {d.name} → {countries.find((c) => c.code === d.correctCountry)?.name ?? d.correctCountry} + {d.revealed && " (revealed)"} +
  • + ))} +
+
+ )} + + {dishes.length > 0 && !allRevealed && ( + + )} +
+
+ ) +} +``` + +Note: The host's dish list shows the correct country (they set it), unlike the player view which hides it. + +- [ ] **Step 3: Create dish results component** + +Create `packages/client/src/components/dish-results.tsx`: + +```tsx +import type { GameState, Country } from "@celebrate-esc/shared" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +interface DishResultsProps { + results: NonNullable + countries: Country[] +} + +export function DishResults({ results, countries }: DishResultsProps) { + return ( + + + Dish Results + + + {results.map((r) => ( +
+

{r.dish.name}

+

+ Answer: {countries.find((c) => c.code === r.dish.correctCountry)?.name} +

+ {r.guesses.length === 0 ? ( +

No guesses

+ ) : ( +
    + {r.guesses.map((g) => ( +
  • + {g.displayName}: {countries.find((c) => c.code === g.guessedCountry)?.name} + {g.correct ? " ✓" : " ✗"} +
  • + ))} +
+ )} +
+ ))} +
+
+ ) +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/client/src/components/dish-list.tsx packages/client/src/components/dish-host.tsx packages/client/src/components/dish-results.tsx +git commit -m "add dish UI components (player list, host controls, results)" +``` + +--- + +## Chunk 5: Route Integration + +### Task 13: Update player view with game UI + +**Files:** +- Modify: `packages/client/src/routes/play.$roomCode.tsx` + +- [ ] **Step 1: Add game UI to player view** + +Import game components and store: + +```typescript +import { PredictionsForm } from "@/components/predictions-form" +import { DishList } from "@/components/dish-list" +import { DishResults } from "@/components/dish-results" +``` + +Update the store destructure: + +```typescript +const { room, mySessionId, connectionStatus, gameState } = useRoomStore() +``` + +Replace the `{/* Game UI will be added in later plans */}` comment and the act-specific conditional renders (lines 88-98) with: + +```tsx +{room.currentAct === "lobby" && !gameState && ( +
+

Waiting for the host to start...

+
+)} + +{gameState && (room.currentAct === "lobby" || room.currentAct === "act1") && ( +
+ + send({ type: "submit_prediction", ...prediction }) + } + /> + + send({ type: "submit_dish_guess", dishId, guessedCountry }) + } + /> +
+)} + +{gameState?.dishResults && ( + +)} + +{room.currentAct === "ended" && ( +
+

The party has ended. Thanks for playing!

+
+)} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/client && bun run build` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/routes/play.\$roomCode.tsx +git commit -m "add game UI to player view" +``` + +--- + +### Task 14: Update host view with game UI + +**Files:** +- Modify: `packages/client/src/routes/host.$roomCode.tsx` + +- [ ] **Step 1: Add game UI to host view** + +Import game components: + +```typescript +import { PredictionsForm } from "@/components/predictions-form" +import { DishList } from "@/components/dish-list" +import { DishHost } from "@/components/dish-host" +import { DishResults } from "@/components/dish-results" +``` + +Update the store destructure: + +```typescript +const { room, mySessionId, connectionStatus, gameState } = useRoomStore() +``` + +Replace the `TabsContent value="play"` contents (currently just PlayerList + comment) with: + +```tsx + + {gameState && (room.currentAct === "lobby" || room.currentAct === "act1") && ( +
+ + send({ type: "submit_prediction", ...prediction }) + } + /> + + send({ type: "submit_dish_guess", dishId, guessedCountry }) + } + /> +
+ )} + {gameState?.dishResults && ( + + )} + +
+``` + +In the `TabsContent value="host"`, add dish host controls inside the `flex flex-col gap-4` div, after the Room Controls card: + +```tsx +{gameState && (room.currentAct === "lobby" || room.currentAct === "act1") && ( + + send({ type: "add_dish", name, correctCountry }) + } + onReveal={() => send({ type: "reveal_dishes" })} + /> +)} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/client && bun run build` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/routes/host.\$roomCode.tsx +git commit -m "add game UI to host view" +``` + +--- + +### Task 15: Update display view + +**Files:** +- Modify: `packages/client/src/routes/display.$roomCode.tsx` + +- [ ] **Step 1: Add dish results to display view** + +Import components: + +```typescript +import { DishResults } from "@/components/dish-results" +``` + +Update the store destructure: + +```typescript +const { room, connectionStatus, gameState } = useRoomStore() +``` + +Add after the lobby display section, before the closing ``: + +```tsx +{gameState?.dishResults && ( +
+ +
+)} + +{room.currentAct === "act1" && gameState && !gameState.dishResults && ( +
+

Act 1 — Predictions & Dishes

+

+ {gameState.dishes.length} dish(es) added +

+
+)} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/client && bun run build` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/routes/display.\$roomCode.tsx +git commit -m "add game status to display view" +``` + +--- + +### Task 16: Fix host dish visibility + +**Files:** +- Modify: `packages/server/src/games/game-manager.ts` + +The host needs to see correct countries for dishes they added (to verify), but the `getGameStateForPlayer` method hides them. Add a separate method or a flag. + +- [ ] **Step 1: Add isHost parameter to getGameStateForPlayer** + +Update the method signature and logic: + +```typescript +getGameStateForPlayer(playerId: string, playerLookup: Map, isHost: boolean): GameState { + return { + lineup, + myPrediction: this.getPrediction(playerId), + predictionsLocked: this.locked, + dishes: this.dishes.map((d) => ({ + id: d.id, + name: d.name, + correctCountry: d.revealed || isHost ? d.correctCountry : "", + revealed: d.revealed, + })), + myDishGuesses: this.getDishGuesses(playerId), + dishResults: this.areAllDishesRevealed() ? this.getDishResults(playerLookup) : null, + } +} +``` + +- [ ] **Step 2: Update the sendGameState helper in handler.ts** + +Update `sendGameState` in `packages/server/src/ws/handler.ts`: + +```typescript +function sendGameState(ws: WSContext, roomCode: string, sessionId: string) { + const gm = roomManager.getGameManager(roomCode) + const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId) + if (!gm || !playerId) return + + const playerLookup = roomManager.getPlayerLookup(roomCode) + const isHost = roomManager.isHost(roomCode, sessionId) + const gameState = gm.getGameStateForPlayer(playerId, playerLookup, isHost) + sendTo(ws, { type: "game_state", gameState }) +} +``` + +- [ ] **Step 3: Update the test for getGameStateForPlayer** + +In `packages/server/tests/game-manager.test.ts`, update the calls to pass the `isHost` parameter: + +```typescript +// Change all calls like: +gm.getGameStateForPlayer("p1", lookup) +// To: +gm.getGameStateForPlayer("p1", lookup, false) +``` + +Add a test for host visibility: + +```typescript +it("shows correct country to host for unrevealed dishes", () => { + gm.addDish("Köttbullar", "SE") + const lookup = new Map() + const state = gm.getGameStateForPlayer("p1", lookup, true) + expect(state.dishes[0]?.correctCountry).toBe("SE") +}) +``` + +- [ ] **Step 4: Run tests** + +Run: `cd packages/server && bun run test` +Expected: all tests pass + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/games/game-manager.ts packages/server/src/ws/handler.ts packages/server/tests/game-manager.test.ts +git commit -m "fix host dish visibility, show correct countries to host" +``` + +--- + +### Task 17: End-to-end verification + +- [ ] **Step 1: Start the dev server** + +Run: `bun run dev` +Expected: both client and server start without errors + +- [ ] **Step 2: Manual smoke test** + +1. Open `http://localhost:5173/` → create a room as host +2. Open a second tab → join the room as a player +3. Verify both see the predictions form and empty dish list +4. Submit a prediction as the player → form updates to show the prediction +5. On the host tab → add a dish via the Host tab +6. Verify the dish appears on the player's phone +7. Submit a dish guess as the player +8. On host tab → click "Reveal All Dishes" +9. Verify results show on both host and player views +10. Advance to Act 2 → verify predictions lock + +- [ ] **Step 3: Run all tests** + +Run: `bun run test` +Expected: all tests pass + +- [ ] **Step 4: Commit any fixes needed** + +If any issues found during smoke test, fix and commit. + +- [ ] **Step 5: Deploy** + +Run: `bash deploy.sh` +Expected: successful deployment + +- [ ] **Step 6: Commit deploy script changes if any** + +If the deploy script needed updates, commit.