08aa68d847
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2048 lines
63 KiB
Markdown
2048 lines
63 KiB
Markdown
# Issue #1 Fixes — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Rework predictions to use full ESC entries with tap-to-assign UI, remove Dish of the Nation, rename acts, add player submission checkmarks, and make lobby code copyable.
|
|
|
|
**Architecture:** All changes flow top-down: shared types first (entry model, prediction model, acts, remove dishes), then server (GameManager, DB schema, WS handler), then client (store, components, routes). No new dependencies needed.
|
|
|
|
**Tech Stack:** Zod, Drizzle ORM, PostgreSQL, React, Zustand, shadcn/ui, TanStack Router
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-12-issue1-fixes-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### Modified
|
|
- `packages/shared/src/constants.ts` — act names
|
|
- `packages/shared/src/game-types.ts` — entry/lineup schemas, prediction schema, gameState schema
|
|
- `packages/shared/src/ws-messages.ts` — remove dish messages, update prediction message
|
|
- `packages/server/src/games/game-manager.ts` — remove dish logic, update predictions, add predictionSubmitted
|
|
- `packages/server/src/games/game-service.ts` — remove dish persistence, update prediction columns
|
|
- `packages/server/src/db/schema.ts` — remove dish tables, update prediction columns, update actEnum
|
|
- `packages/server/src/rooms/room-manager.ts` — act name reference in advanceAct
|
|
- `packages/server/src/ws/handler.ts` — remove dish handlers, update prediction handler, update act lock
|
|
- `packages/server/tests/game-manager.test.ts` — rewrite for new model
|
|
- `packages/server/tests/ws-handler.test.ts` — update for changed messages
|
|
- `packages/client/src/stores/room-store.ts` — remove dish state, simplify
|
|
- `packages/client/src/hooks/use-websocket.ts` — remove dish message handlers
|
|
- `packages/client/src/components/predictions-form.tsx` — rewrite as tap-to-assign
|
|
- `packages/client/src/components/player-list.tsx` — add prediction checkmark
|
|
- `packages/client/src/components/room-header.tsx` — update act labels
|
|
- `packages/client/src/routes/play.$roomCode.tsx` — remove dish UI, update act refs
|
|
- `packages/client/src/routes/host.$roomCode.tsx` — remove dish UI, update act labels
|
|
- `packages/client/src/routes/display.$roomCode.tsx` — remove dish UI, add copy-to-clipboard
|
|
|
|
### Deleted
|
|
- `packages/client/src/components/dish-list.tsx`
|
|
- `packages/client/src/components/dish-host.tsx`
|
|
- `packages/client/src/components/dish-results.tsx`
|
|
- `packages/server/data/esc-2026.json`
|
|
|
|
### Created
|
|
- `packages/server/data/esc-2025.json` — full ESC 2025 entry data with flag, artist, song
|
|
|
|
---
|
|
|
|
## Chunk 1: Shared Types + Data
|
|
|
|
### Task 1: Update shared constants (acts)
|
|
|
|
**Files:**
|
|
- Modify: `packages/shared/src/constants.ts`
|
|
|
|
- [ ] **Step 1: Update ACTS array and remove unused constants**
|
|
|
|
Replace the entire 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", "pre-show", "live-event", "scoring", "ended"] as const
|
|
export type Act = (typeof ACTS)[number]
|
|
|
|
export const ACT_LABELS: Record<Act, string> = {
|
|
lobby: "Lobby",
|
|
"pre-show": "Pre-Show",
|
|
"live-event": "Live Event",
|
|
scoring: "Scoring",
|
|
ended: "Ended",
|
|
}
|
|
```
|
|
|
|
Remove `JURY_RATING_MIN`, `JURY_RATING_MAX`, `BINGO_GRID_SIZE`, `BINGO_TOTAL_SQUARES` — they are unused and belong to unimplemented plans.
|
|
|
|
- [ ] **Step 2: Verify the shared package builds**
|
|
|
|
Run: `cd packages/shared && bun run build 2>&1 || true`
|
|
|
|
This will have errors because downstream files still reference old types — that's expected. Verify the constants file itself has no syntax errors by checking the output.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/shared/src/constants.ts
|
|
git commit -m "update acts to pre-show/live-event/scoring, add ACT_LABELS, remove unused constants"
|
|
```
|
|
|
|
### Task 2: Update shared game types
|
|
|
|
**Files:**
|
|
- Modify: `packages/shared/src/game-types.ts`
|
|
|
|
- [ ] **Step 1: Rewrite game-types.ts with new entry/prediction model and no dishes**
|
|
|
|
Replace the entire file:
|
|
|
|
```ts
|
|
import { z } from "zod"
|
|
|
|
// ─── Entry Lineup ───────────────────────────────────────────────────
|
|
|
|
export const countrySchema = z.object({
|
|
code: z.string(),
|
|
name: z.string(),
|
|
flag: z.string(),
|
|
})
|
|
|
|
export type Country = z.infer<typeof countrySchema>
|
|
|
|
export const entrySchema = z.object({
|
|
country: countrySchema,
|
|
artist: z.string(),
|
|
song: z.string(),
|
|
})
|
|
|
|
export type Entry = z.infer<typeof entrySchema>
|
|
|
|
export const lineupSchema = z.object({
|
|
year: z.number(),
|
|
entries: z.array(entrySchema),
|
|
})
|
|
|
|
export type Lineup = z.infer<typeof lineupSchema>
|
|
|
|
// ─── Predictions ────────────────────────────────────────────────────
|
|
|
|
export const predictionSchema = z.object({
|
|
playerId: z.string().uuid(),
|
|
first: z.string(),
|
|
second: z.string(),
|
|
third: z.string(),
|
|
last: z.string(),
|
|
})
|
|
|
|
export type Prediction = z.infer<typeof predictionSchema>
|
|
|
|
// ─── Game State (sent to clients) ───────────────────────────────────
|
|
|
|
export const gameStateSchema = z.object({
|
|
lineup: lineupSchema,
|
|
myPrediction: predictionSchema.nullable(),
|
|
predictionsLocked: z.boolean(),
|
|
predictionSubmitted: z.record(z.string(), z.boolean()),
|
|
})
|
|
|
|
export type GameState = z.infer<typeof gameStateSchema>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/shared/src/game-types.ts
|
|
git commit -m "rewrite game types: entry model with flag/artist/song, ordered predictions, remove dishes"
|
|
```
|
|
|
|
### Task 3: Update shared WS messages
|
|
|
|
**Files:**
|
|
- Modify: `packages/shared/src/ws-messages.ts`
|
|
|
|
- [ ] **Step 1: Remove dish messages and update prediction message**
|
|
|
|
Replace the entire file:
|
|
|
|
```ts
|
|
import { z } from "zod"
|
|
import { ACTS } from "./constants"
|
|
import { gameStateSchema } from "./game-types"
|
|
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"),
|
|
})
|
|
|
|
export const submitPredictionMessage = z.object({
|
|
type: z.literal("submit_prediction"),
|
|
first: z.string(),
|
|
second: z.string(),
|
|
third: z.string(),
|
|
last: z.string(),
|
|
})
|
|
|
|
export const clientMessage = z.discriminatedUnion("type", [
|
|
joinRoomMessage,
|
|
reconnectMessage,
|
|
advanceActMessage,
|
|
endRoomMessage,
|
|
submitPredictionMessage,
|
|
])
|
|
|
|
export type ClientMessage = z.infer<typeof clientMessage>
|
|
|
|
// ─── 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(),
|
|
})
|
|
|
|
export const gameStateMessage = z.object({
|
|
type: z.literal("game_state"),
|
|
gameState: gameStateSchema,
|
|
})
|
|
|
|
export const predictionsLockedMessage = z.object({
|
|
type: z.literal("predictions_locked"),
|
|
})
|
|
|
|
export const serverMessage = z.discriminatedUnion("type", [
|
|
roomStateMessage,
|
|
playerJoinedMessage,
|
|
playerDisconnectedMessage,
|
|
playerReconnectedMessage,
|
|
actChangedMessage,
|
|
roomEndedMessage,
|
|
errorMessage,
|
|
gameStateMessage,
|
|
predictionsLockedMessage,
|
|
])
|
|
|
|
export type ServerMessage = z.infer<typeof serverMessage>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/shared/src/ws-messages.ts
|
|
git commit -m "remove dish WS messages, update prediction message to first/second/third/last"
|
|
```
|
|
|
|
### Task 4: Create ESC 2025 data file
|
|
|
|
**Files:**
|
|
- Create: `packages/server/data/esc-2025.json`
|
|
- Delete: `packages/server/data/esc-2026.json`
|
|
|
|
- [ ] **Step 1: Create esc-2025.json with full entry data**
|
|
|
|
Create the file with ESC 2025 entries. Each entry needs `country: { code, name, flag }`, `artist`, and `song`. Use real ESC 2025 Basel contest data. Here is the data (verify artist/song accuracy if unsure — web search "Eurovision 2025 entries" for the official list):
|
|
|
|
```json
|
|
{
|
|
"year": 2025,
|
|
"entries": [
|
|
{ "country": { "code": "AL", "name": "Albania", "flag": "🇦🇱" }, "artist": "Shkodra Elektronike", "song": "Zjerm" },
|
|
{ "country": { "code": "AM", "name": "Armenia", "flag": "🇦🇲" }, "artist": "Parg", "song": "Survivor" },
|
|
{ "country": { "code": "AU", "name": "Australia", "flag": "🇦🇺" }, "artist": "Go-Jo", "song": "Milkshake Man" },
|
|
{ "country": { "code": "AT", "name": "Austria", "flag": "🇦🇹" }, "artist": "JJ", "song": "Wasted Love" },
|
|
{ "country": { "code": "AZ", "name": "Azerbaijan", "flag": "🇦🇿" }, "artist": "Mamagama", "song": "Run" },
|
|
{ "country": { "code": "BE", "name": "Belgium", "flag": "🇧🇪" }, "artist": "Red Sebastian", "song": "Strobe Lights" },
|
|
{ "country": { "code": "HR", "name": "Croatia", "flag": "🇭🇷" }, "artist": "Marko Bošnjak", "song": "Lying to Myself" },
|
|
{ "country": { "code": "CY", "name": "Cyprus", "flag": "🇨🇾" }, "artist": "Theo Evan", "song": "Aponi" },
|
|
{ "country": { "code": "CZ", "name": "Czechia", "flag": "🇨🇿" }, "artist": "Adonxs", "song": "Kiss Kiss Goodbye" },
|
|
{ "country": { "code": "DK", "name": "Denmark", "flag": "🇩🇰" }, "artist": "Sissal", "song": "Hallucination" },
|
|
{ "country": { "code": "EE", "name": "Estonia", "flag": "🇪🇪" }, "artist": "Tommy Cash", "song": "Espresso Macchiato" },
|
|
{ "country": { "code": "FI", "name": "Finland", "flag": "🇫🇮" }, "artist": "Erika Vikman", "song": "Ich Komme" },
|
|
{ "country": { "code": "FR", "name": "France", "flag": "🇫🇷" }, "artist": "Louane", "song": "Maman" },
|
|
{ "country": { "code": "GE", "name": "Georgia", "flag": "🇬🇪" }, "artist": "Mariam Bigvava", "song": "Ertad Moval" },
|
|
{ "country": { "code": "DE", "name": "Germany", "flag": "🇩🇪" }, "artist": "Abor", "song": "Süden" },
|
|
{ "country": { "code": "GR", "name": "Greece", "flag": "🇬🇷" }, "artist": "Klavdia", "song": "Asteromata" },
|
|
{ "country": { "code": "IS", "name": "Iceland", "flag": "🇮🇸" }, "artist": "VÍK", "song": "Róa" },
|
|
{ "country": { "code": "IE", "name": "Ireland", "flag": "🇮🇪" }, "artist": "Óige", "song": "Song of the Sirens" },
|
|
{ "country": { "code": "IL", "name": "Israel", "flag": "🇮🇱" }, "artist": "Yuval Raphael", "song": "New Day Will Rise" },
|
|
{ "country": { "code": "IT", "name": "Italy", "flag": "🇮🇹" }, "artist": "Lucio Corsi", "song": "Volano le Rondini" },
|
|
{ "country": { "code": "LV", "name": "Latvia", "flag": "🇱🇻" }, "artist": "Tautumeitas", "song": "Bur man laimi" },
|
|
{ "country": { "code": "LT", "name": "Lithuania", "flag": "🇱🇹" }, "artist": "Katažina Zvonkuvienė", "song": "Tavo Akys" },
|
|
{ "country": { "code": "LU", "name": "Luxembourg", "flag": "🇱🇺" }, "artist": "Laura Music", "song": "La Poupée" },
|
|
{ "country": { "code": "MT", "name": "Malta", "flag": "🇲🇹" }, "artist": "Miriana Conte", "song": "Serving" },
|
|
{ "country": { "code": "MD", "name": "Moldova", "flag": "🇲🇩" }, "artist": "Natalia Barbu", "song": "Fight" },
|
|
{ "country": { "code": "ME", "name": "Montenegro", "flag": "🇲🇪" }, "artist": "Nina Žižić", "song": "Dobrodošli" },
|
|
{ "country": { "code": "NL", "name": "Netherlands", "flag": "🇳🇱" }, "artist": "Claude", "song": "C'est La Vie" },
|
|
{ "country": { "code": "MK", "name": "North Macedonia", "flag": "🇲🇰" }, "artist": "Ina", "song": "Sunrise" },
|
|
{ "country": { "code": "NO", "name": "Norway", "flag": "🇳🇴" }, "artist": "Kyle Alessandro", "song": "Lighter" },
|
|
{ "country": { "code": "PL", "name": "Poland", "flag": "🇵🇱" }, "artist": "Justyna Steczkowska", "song": "Gaja" },
|
|
{ "country": { "code": "PT", "name": "Portugal", "flag": "🇵🇹" }, "artist": "Napa", "song": "Deslocado" },
|
|
{ "country": { "code": "RO", "name": "Romania", "flag": "🇷🇴" }, "artist": "Lucian Colareza", "song": "The Road" },
|
|
{ "country": { "code": "SM", "name": "San Marino", "flag": "🇸🇲" }, "artist": "Gabry Ponte", "song": "Tutta L'Italia" },
|
|
{ "country": { "code": "RS", "name": "Serbia", "flag": "🇷🇸" }, "artist": "Breskvica", "song": "Mango" },
|
|
{ "country": { "code": "SI", "name": "Slovenia", "flag": "🇸🇮" }, "artist": "Klemen Slakonja", "song": "How Much Time Do We Have Left" },
|
|
{ "country": { "code": "ES", "name": "Spain", "flag": "🇪🇸" }, "artist": "Melody", "song": "Esa Diva" },
|
|
{ "country": { "code": "SE", "name": "Sweden", "flag": "🇸🇪" }, "artist": "KAJ", "song": "Bara Bansen" },
|
|
{ "country": { "code": "CH", "name": "Switzerland", "flag": "🇨🇭" }, "artist": "Zoë Më", "song": "Voyage" },
|
|
{ "country": { "code": "UA", "name": "Ukraine", "flag": "🇺🇦" }, "artist": "Ziferblat", "song": "Bird of Pray" },
|
|
{ "country": { "code": "GB", "name": "United Kingdom", "flag": "🇬🇧" }, "artist": "Remember Monday", "song": "What the Hell Just Happened?" }
|
|
]
|
|
}
|
|
```
|
|
|
|
**Note:** Verify this data against the official ESC 2025 entries at https://eurovision.tv/event/basel-2025/participants. Some entries may need correction.
|
|
|
|
- [ ] **Step 2: Delete the old data file**
|
|
|
|
```bash
|
|
rm packages/server/data/esc-2026.json
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/server/data/esc-2025.json
|
|
git add -u packages/server/data/esc-2026.json
|
|
git commit -m "replace esc-2026 country-only data with esc-2025 full entries (flag, artist, song)"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: Server Changes
|
|
|
|
### Task 5: Rewrite GameManager
|
|
|
|
**Files:**
|
|
- Modify: `packages/server/src/games/game-manager.ts`
|
|
|
|
- [ ] **Step 1: Rewrite game-manager.ts — remove dishes, update predictions, add predictionSubmitted**
|
|
|
|
Replace the entire file:
|
|
|
|
```ts
|
|
import type { Prediction, GameState, Lineup } from "@celebrate-esc/shared"
|
|
import lineupData from "../../data/esc-2025.json"
|
|
|
|
const lineup: Lineup = lineupData as Lineup
|
|
const countryCodes = new Set(lineup.entries.map((e) => e.country.code))
|
|
|
|
export class GameManager {
|
|
private predictions = new Map<string, Prediction>() // playerId → prediction
|
|
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,
|
|
first: string,
|
|
second: string,
|
|
third: string,
|
|
last: string,
|
|
): { success: true } | { error: string } {
|
|
if (this.locked) return { error: "Predictions are locked" }
|
|
|
|
const allPicks = [first, second, third, last]
|
|
for (const code of allPicks) {
|
|
if (!this.isValidCountry(code)) return { error: `Invalid country: ${code}` }
|
|
}
|
|
|
|
if (new Set(allPicks).size !== 4) {
|
|
return { error: "All 4 picks must be different countries" }
|
|
}
|
|
|
|
this.predictions.set(playerId, { playerId, first, second, third, last })
|
|
return { success: true }
|
|
}
|
|
|
|
getPrediction(playerId: string): Prediction | null {
|
|
return this.predictions.get(playerId) ?? null
|
|
}
|
|
|
|
getAllPredictions(): Map<string, Prediction> {
|
|
return this.predictions
|
|
}
|
|
|
|
hasPrediction(playerId: string): boolean {
|
|
return this.predictions.has(playerId)
|
|
}
|
|
|
|
// ─── State for client ───────────────────────────────────────────
|
|
|
|
private buildPredictionSubmitted(playerIds: string[]): Record<string, boolean> {
|
|
const result: Record<string, boolean> = {}
|
|
for (const id of playerIds) {
|
|
result[id] = this.predictions.has(id)
|
|
}
|
|
return result
|
|
}
|
|
|
|
getGameStateForPlayer(playerId: string, allPlayerIds: string[]): GameState {
|
|
return {
|
|
lineup,
|
|
myPrediction: this.getPrediction(playerId),
|
|
predictionsLocked: this.locked,
|
|
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
|
|
}
|
|
}
|
|
|
|
getGameStateForDisplay(allPlayerIds: string[]): GameState {
|
|
return {
|
|
lineup,
|
|
myPrediction: null,
|
|
predictionsLocked: this.locked,
|
|
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/server/src/games/game-manager.ts
|
|
git commit -m "rewrite game manager: ordered predictions, predictionSubmitted, remove dishes"
|
|
```
|
|
|
|
### Task 6: Update DB schema
|
|
|
|
**Files:**
|
|
- Modify: `packages/server/src/db/schema.ts`
|
|
|
|
- [ ] **Step 1: Update schema — remove dish tables, update prediction columns, update actEnum**
|
|
|
|
Replace the entire file:
|
|
|
|
```ts
|
|
import { boolean, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
|
|
|
|
export const actEnum = pgEnum("act", ["lobby", "pre-show", "live-event", "scoring", "ended"])
|
|
|
|
// ─── 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 ────────────────────────────────────────────────────
|
|
|
|
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),
|
|
first: varchar("first").notNull(),
|
|
second: varchar("second").notNull(),
|
|
third: varchar("third").notNull(),
|
|
last: varchar("last").notNull(),
|
|
})
|
|
```
|
|
|
|
Note: This removes `dishes`, `dishGuesses`, `juryRounds`, `juryVotes`, `bingoCards`, `quizRounds`, `quizAnswers` tables, and the `juryRoundStatusEnum` and `quizRoundStatusEnum` enums. These belonged to unimplemented plans and can be re-added when needed.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/server/src/db/schema.ts
|
|
git commit -m "update DB schema: rename acts, update prediction columns, remove dish/jury/bingo/quiz tables"
|
|
```
|
|
|
|
### Task 7: Update GameService
|
|
|
|
**Files:**
|
|
- Modify: `packages/server/src/games/game-service.ts`
|
|
|
|
- [ ] **Step 1: Remove dish persistence, update prediction columns**
|
|
|
|
Replace the entire file:
|
|
|
|
```ts
|
|
import { eq, and } from "drizzle-orm"
|
|
import type { Database } from "../db/client"
|
|
import { predictions } from "../db/schema"
|
|
|
|
export class GameService {
|
|
constructor(private db: Database) {}
|
|
|
|
async persistPrediction(data: {
|
|
playerId: string
|
|
roomId: string
|
|
first: string
|
|
second: string
|
|
third: string
|
|
last: 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,
|
|
first: data.first,
|
|
second: data.second,
|
|
third: data.third,
|
|
last: data.last,
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/server/src/games/game-service.ts
|
|
git commit -m "simplify game service: remove dish persistence, update prediction columns"
|
|
```
|
|
|
|
### Task 8: Update WS handler
|
|
|
|
**Files:**
|
|
- Modify: `packages/server/src/ws/handler.ts`
|
|
|
|
- [ ] **Step 1: Remove dish handlers, update prediction handler, update act lock trigger**
|
|
|
|
Replace the entire file:
|
|
|
|
```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
|
|
interface Connection {
|
|
ws: WSContext
|
|
sessionId: string | null
|
|
}
|
|
const roomConnections = new Map<string, Set<Connection>>()
|
|
|
|
function getConnections(roomCode: string): Set<Connection> {
|
|
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 })
|
|
}
|
|
|
|
function sendGameState(ws: WSContext, roomCode: string, sessionId: string) {
|
|
const gm = roomManager.getGameManager(roomCode)
|
|
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
|
|
if (!gm || !playerId) return
|
|
|
|
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
|
|
const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds)
|
|
sendTo(ws, { type: "game_state", gameState })
|
|
}
|
|
|
|
function sendDisplayGameState(ws: WSContext, roomCode: string) {
|
|
const gm = roomManager.getGameManager(roomCode)
|
|
if (!gm) return
|
|
|
|
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
|
|
const gameState = gm.getGameStateForDisplay(allPlayerIds)
|
|
sendTo(ws, { type: "game_state", gameState })
|
|
}
|
|
|
|
function broadcastGameStateToAll(roomCode: string) {
|
|
const conns = roomConnections.get(roomCode)
|
|
if (!conns) return
|
|
for (const conn of conns) {
|
|
try {
|
|
if (conn.sessionId) {
|
|
sendGameState(conn.ws, roomCode, conn.sessionId)
|
|
} else {
|
|
sendDisplayGameState(conn.ws, roomCode)
|
|
}
|
|
} catch {
|
|
// Connection may be closed
|
|
}
|
|
}
|
|
}
|
|
|
|
let registered = false
|
|
|
|
export function registerWebSocketRoutes() {
|
|
if (registered) return
|
|
registered = true
|
|
|
|
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) {
|
|
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)!,
|
|
})
|
|
sendGameState(ws, roomCode, sessionId)
|
|
broadcast(roomCode, {
|
|
type: "player_reconnected",
|
|
playerId: result.playerId,
|
|
})
|
|
}
|
|
} else {
|
|
sendTo(ws, {
|
|
type: "room_state",
|
|
room: roomManager.getRoom(roomCode)!,
|
|
})
|
|
sendDisplayGameState(ws, 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)
|
|
|
|
sendTo(ws, {
|
|
type: "room_state",
|
|
room: roomManager.getRoom(roomCode)!,
|
|
sessionId: result.sessionId,
|
|
})
|
|
sendGameState(ws, roomCode, result.sessionId)
|
|
|
|
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)!,
|
|
})
|
|
sendGameState(ws, roomCode, msg.sessionId)
|
|
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,
|
|
})
|
|
// Lock predictions when moving from pre-show to live-event
|
|
if (result.newAct === "live-event") {
|
|
const gm = roomManager.getGameManager(roomCode)
|
|
if (gm) {
|
|
gm.lockPredictions()
|
|
broadcast(roomCode, { type: "predictions_locked" })
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
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.first, msg.second, msg.third, msg.last)
|
|
if ("error" in result) {
|
|
sendError(ws, result.error)
|
|
return
|
|
}
|
|
|
|
// Broadcast game state to all so everyone sees the checkmark update
|
|
broadcastGameStateToAll(roomCode)
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
```
|
|
|
|
Key changes:
|
|
- `sendGameState` and `sendDisplayGameState` now pass `allPlayerIds` instead of `playerLookup`
|
|
- `submit_prediction` now broadcasts game state to ALL connections (so checkmarks update for everyone)
|
|
- `broadcastGameStateToAll` helper sends personalized game state to each connection
|
|
- Act lock trigger changed from `"act2"` to `"live-event"`
|
|
- All dish message handlers removed
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/server/src/ws/handler.ts
|
|
git commit -m "update WS handler: remove dish handlers, broadcast prediction checkmarks, lock on live-event"
|
|
```
|
|
|
|
### Task 9: Update RoomManager
|
|
|
|
**Files:**
|
|
- Modify: `packages/server/src/rooms/room-manager.ts`
|
|
|
|
- [ ] **Step 1: Add getAllPlayerIds method, remove getPlayerLookup**
|
|
|
|
The `getPlayerLookup` method is no longer needed (was used for dish results). Replace it with `getAllPlayerIds`. Also update the import — `ACTS` is unchanged in shape but values are different, no code change needed for the manager itself since it uses the type system.
|
|
|
|
In `packages/server/src/rooms/room-manager.ts`, replace the `getPlayerLookup` method (lines 151-159) with:
|
|
|
|
```ts
|
|
getAllPlayerIds(code: string): string[] {
|
|
const room = this.rooms.get(code)
|
|
if (!room) return []
|
|
return Array.from(room.players.values()).map((p) => p.id)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/server/src/rooms/room-manager.ts
|
|
git commit -m "replace getPlayerLookup with getAllPlayerIds in room manager"
|
|
```
|
|
|
|
### Task 10: Rewrite server tests
|
|
|
|
**Files:**
|
|
- Modify: `packages/server/tests/game-manager.test.ts`
|
|
- Modify: `packages/server/tests/ws-handler.test.ts`
|
|
|
|
- [ ] **Step 1: Rewrite game-manager.test.ts**
|
|
|
|
Replace the entire file:
|
|
|
|
```ts
|
|
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 2025 lineup", () => {
|
|
const lineup = gm.getLineup()
|
|
expect(lineup.year).toBe(2025)
|
|
expect(lineup.entries.length).toBeGreaterThan(20)
|
|
expect(lineup.entries[0]).toHaveProperty("country")
|
|
expect(lineup.entries[0]).toHaveProperty("artist")
|
|
expect(lineup.entries[0]).toHaveProperty("song")
|
|
expect(lineup.entries[0]?.country).toHaveProperty("flag")
|
|
})
|
|
|
|
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", "GB")
|
|
expect(result).toEqual({ success: true })
|
|
expect(gm.getPrediction("p1")).toEqual({
|
|
playerId: "p1",
|
|
first: "SE",
|
|
second: "DE",
|
|
third: "IT",
|
|
last: "GB",
|
|
})
|
|
})
|
|
|
|
it("rejects prediction with invalid country", () => {
|
|
const result = gm.submitPrediction("p1", "XX", "DE", "IT", "GB")
|
|
expect(result).toEqual({ error: "Invalid country: XX" })
|
|
})
|
|
|
|
it("rejects duplicate picks", () => {
|
|
const result = gm.submitPrediction("p1", "SE", "SE", "IT", "GB")
|
|
expect(result).toEqual({ error: "All 4 picks must be different countries" })
|
|
})
|
|
|
|
it("rejects last same as first", () => {
|
|
const result = gm.submitPrediction("p1", "SE", "DE", "IT", "SE")
|
|
expect(result).toEqual({ error: "All 4 picks must be different countries" })
|
|
})
|
|
|
|
it("allows overwriting a prediction", () => {
|
|
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
|
gm.submitPrediction("p1", "NO", "DE", "IT", "GB")
|
|
expect(gm.getPrediction("p1")?.first).toBe("NO")
|
|
})
|
|
|
|
it("rejects prediction when locked", () => {
|
|
gm.lockPredictions()
|
|
const result = gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
|
expect(result).toEqual({ error: "Predictions are locked" })
|
|
})
|
|
|
|
it("tracks prediction submission status", () => {
|
|
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
|
expect(gm.hasPrediction("p1")).toBe(true)
|
|
expect(gm.hasPrediction("p2")).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("getGameStateForPlayer", () => {
|
|
it("includes only the requesting player's prediction", () => {
|
|
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
|
gm.submitPrediction("p2", "NO", "DE", "IT", "GB")
|
|
const state = gm.getGameStateForPlayer("p1", ["p1", "p2"])
|
|
expect(state.myPrediction?.first).toBe("SE")
|
|
})
|
|
|
|
it("includes predictionSubmitted for all players", () => {
|
|
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
|
const state = gm.getGameStateForPlayer("p1", ["p1", "p2"])
|
|
expect(state.predictionSubmitted).toEqual({ p1: true, p2: false })
|
|
})
|
|
})
|
|
|
|
describe("getGameStateForDisplay", () => {
|
|
it("returns null myPrediction", () => {
|
|
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
|
const state = gm.getGameStateForDisplay(["p1"])
|
|
expect(state.myPrediction).toBeNull()
|
|
})
|
|
|
|
it("includes predictionSubmitted", () => {
|
|
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
|
|
const state = gm.getGameStateForDisplay(["p1", "p2"])
|
|
expect(state.predictionSubmitted).toEqual({ p1: true, p2: false })
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Update ws-handler.test.ts — fix act references**
|
|
|
|
In `packages/server/tests/ws-handler.test.ts`, no structural changes are needed — the test creates a room, connects via WS, and tests join. The `waitForMessageType` helper already handles draining game_state messages. The tests should still pass since the WS handler changes are backward-compatible at the protocol level for room_state and player_joined messages.
|
|
|
|
Run the tests to verify:
|
|
|
|
```bash
|
|
cd packages/server && bun test
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/server/tests/game-manager.test.ts packages/server/tests/ws-handler.test.ts
|
|
git commit -m "rewrite game manager tests for new prediction model"
|
|
```
|
|
|
|
### Task 11: Push DB schema to Postgres
|
|
|
|
- [ ] **Step 1: Push the new schema to the database**
|
|
|
|
Since there is no production data to preserve, use Drizzle push to recreate the schema:
|
|
|
|
```bash
|
|
cd packages/server && bunx drizzle-kit push
|
|
```
|
|
|
|
If the push fails due to enum conflicts, connect to the database and manually drop the old enums and tables first:
|
|
|
|
```bash
|
|
# Only if push fails:
|
|
psql -p 5433 esc -c "DROP TABLE IF EXISTS dish_guesses, dishes, quiz_answers, quiz_rounds, bingo_cards, jury_votes, jury_rounds CASCADE;"
|
|
psql -p 5433 esc -c "DROP TYPE IF EXISTS jury_round_status, quiz_round_status CASCADE;"
|
|
# Then re-run: bunx drizzle-kit push
|
|
```
|
|
|
|
- [ ] **Step 2: Commit (no file changes, but verify)**
|
|
|
|
No git commit needed — this is a runtime operation.
|
|
|
|
---
|
|
|
|
## Chunk 3: Client Changes
|
|
|
|
### Task 12: Simplify room store and WebSocket hook
|
|
|
|
**Files:**
|
|
- Modify: `packages/client/src/stores/room-store.ts`
|
|
- Modify: `packages/client/src/hooks/use-websocket.ts`
|
|
|
|
- [ ] **Step 1: Simplify room-store.ts — remove all dish state**
|
|
|
|
Replace the entire file:
|
|
|
|
```tsx
|
|
import { create } from "zustand"
|
|
import type { RoomState, Player, GameState } from "@celebrate-esc/shared"
|
|
|
|
interface RoomStore {
|
|
room: RoomState | null
|
|
mySessionId: string | null
|
|
connectionStatus: "disconnected" | "connecting" | "connected"
|
|
gameState: GameState | null
|
|
|
|
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
|
|
setGameState: (gameState: GameState) => void
|
|
lockPredictions: () => void
|
|
reset: () => void
|
|
}
|
|
|
|
export const useRoomStore = create<RoomStore>((set) => ({
|
|
room: null,
|
|
mySessionId: null,
|
|
connectionStatus: "disconnected",
|
|
gameState: null,
|
|
|
|
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
|
|
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 } }
|
|
}),
|
|
|
|
setGameState: (gameState) => set({ gameState }),
|
|
|
|
lockPredictions: () =>
|
|
set((state) => {
|
|
if (!state.gameState) return state
|
|
return {
|
|
gameState: { ...state.gameState, predictionsLocked: true },
|
|
}
|
|
}),
|
|
|
|
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null }),
|
|
}))
|
|
```
|
|
|
|
- [ ] **Step 2: Simplify use-websocket.ts — remove dish message handlers**
|
|
|
|
Replace the entire file:
|
|
|
|
```tsx
|
|
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<WebSocket | null>(null)
|
|
const {
|
|
setRoom,
|
|
setMySessionId,
|
|
setConnectionStatus,
|
|
updatePlayerConnected,
|
|
addPlayer,
|
|
setAct,
|
|
reset,
|
|
setGameState,
|
|
lockPredictions,
|
|
} = 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 base = import.meta.env.BASE_URL.replace(/\/$/, "")
|
|
const wsUrl = sessionId
|
|
? `${protocol}//${window.location.host}${base}/api/ws/${roomCode}?sessionId=${sessionId}`
|
|
: `${protocol}//${window.location.host}${base}/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) {
|
|
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 "game_state":
|
|
setGameState(msg.gameState)
|
|
break
|
|
case "predictions_locked":
|
|
lockPredictions()
|
|
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, setGameState, lockPredictions])
|
|
|
|
return { send }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/client/src/stores/room-store.ts packages/client/src/hooks/use-websocket.ts
|
|
git commit -m "simplify room store, remove dish message handlers from websocket hook"
|
|
```
|
|
|
|
### Task 13: Rewrite predictions form (tap-to-assign)
|
|
|
|
**Files:**
|
|
- Modify: `packages/client/src/components/predictions-form.tsx`
|
|
|
|
- [ ] **Step 1: Rewrite as tap-to-assign UI**
|
|
|
|
Replace the entire file:
|
|
|
|
```tsx
|
|
import { useState } from "react"
|
|
import type { Entry, Prediction } from "@celebrate-esc/shared"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
|
|
type SlotKey = "first" | "second" | "third" | "last"
|
|
|
|
const SLOTS: { key: SlotKey; label: string }[] = [
|
|
{ key: "first", label: "1st Place" },
|
|
{ key: "second", label: "2nd Place" },
|
|
{ key: "third", label: "3rd Place" },
|
|
{ key: "last", label: "Last Place" },
|
|
]
|
|
|
|
function formatEntry(entry: Entry): string {
|
|
return `${entry.country.flag} ${entry.artist} — ${entry.song}`
|
|
}
|
|
|
|
interface PredictionsFormProps {
|
|
entries: Entry[]
|
|
existingPrediction: Prediction | null
|
|
locked: boolean
|
|
onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void
|
|
}
|
|
|
|
export function PredictionsForm({ entries, existingPrediction, locked, onSubmit }: PredictionsFormProps) {
|
|
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
|
|
if (existingPrediction) {
|
|
return {
|
|
first: existingPrediction.first,
|
|
second: existingPrediction.second,
|
|
third: existingPrediction.third,
|
|
last: existingPrediction.last,
|
|
}
|
|
}
|
|
return { first: null, second: null, third: null, last: null }
|
|
})
|
|
const [pickerForEntry, setPickerForEntry] = useState<string | null>(null)
|
|
|
|
const assignedCodes = new Set(Object.values(slots).filter(Boolean))
|
|
const emptySlots = SLOTS.filter((s) => !slots[s.key])
|
|
const allFilled = SLOTS.every((s) => slots[s.key])
|
|
|
|
function findEntry(code: string): Entry | undefined {
|
|
return entries.find((e) => e.country.code === code)
|
|
}
|
|
|
|
function assignToSlot(entryCode: string, slotKey: SlotKey) {
|
|
setSlots((prev) => ({ ...prev, [slotKey]: entryCode }))
|
|
setPickerForEntry(null)
|
|
}
|
|
|
|
function removeFromSlot(slotKey: SlotKey) {
|
|
setSlots((prev) => ({ ...prev, [slotKey]: null }))
|
|
}
|
|
|
|
if (locked) {
|
|
if (!existingPrediction) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-6 text-center text-muted-foreground">
|
|
Predictions are locked. You didn't submit one in time.
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Your Predictions (locked)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-2">
|
|
{SLOTS.map((slot) => {
|
|
const entry = findEntry(existingPrediction[slot.key])
|
|
return (
|
|
<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
|
|
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
|
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// Already submitted — show read-only with option to resubmit later
|
|
if (existingPrediction && allFilled) {
|
|
const hasChanges = SLOTS.some((s) => slots[s.key] !== existingPrediction[s.key])
|
|
if (!hasChanges) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Your Predictions (submitted)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-2">
|
|
{SLOTS.map((slot) => {
|
|
const entry = findEntry(existingPrediction[slot.key])
|
|
return (
|
|
<div key={slot.key} className="flex items-center justify-between rounded-md border p-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
|
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeFromSlot(slot.key)}
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
change
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Predictions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
{/* Slot cards */}
|
|
<div className="flex flex-col gap-2">
|
|
{SLOTS.map((slot) => {
|
|
const code = slots[slot.key]
|
|
const entry = code ? findEntry(code) : null
|
|
return (
|
|
<div
|
|
key={slot.key}
|
|
className={`flex items-center justify-between rounded-md border p-2 ${
|
|
code ? "border-primary/30 bg-primary/5" : "border-dashed"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
|
|
{entry ? (
|
|
<span className="text-sm">{formatEntry(entry)}</span>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">Tap an entry below</span>
|
|
)}
|
|
</div>
|
|
{code && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeFromSlot(slot.key)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
aria-label={`Remove ${slot.label}`}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Submit button */}
|
|
{allFilled && (
|
|
<Button
|
|
onClick={() =>
|
|
onSubmit({
|
|
first: slots.first!,
|
|
second: slots.second!,
|
|
third: slots.third!,
|
|
last: slots.last!,
|
|
})
|
|
}
|
|
>
|
|
{existingPrediction ? "Update Prediction" : "Submit Prediction"}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Entry list */}
|
|
<div className="flex flex-col gap-1">
|
|
<h4 className="text-sm font-medium text-muted-foreground">Entries</h4>
|
|
{entries.map((entry) => {
|
|
const isAssigned = assignedCodes.has(entry.country.code)
|
|
const isPickerOpen = pickerForEntry === entry.country.code
|
|
return (
|
|
<div key={entry.country.code}>
|
|
<button
|
|
type="button"
|
|
disabled={isAssigned}
|
|
onClick={() => {
|
|
if (emptySlots.length === 1) {
|
|
assignToSlot(entry.country.code, emptySlots[0]!.key)
|
|
} else {
|
|
setPickerForEntry(isPickerOpen ? null : entry.country.code)
|
|
}
|
|
}}
|
|
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
|
isAssigned
|
|
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
|
|
: isPickerOpen
|
|
? "border-primary bg-primary/5"
|
|
: "hover:bg-muted"
|
|
}`}
|
|
>
|
|
{formatEntry(entry)}
|
|
</button>
|
|
{isPickerOpen && !isAssigned && (
|
|
<div className="mt-1 ml-4 flex gap-1">
|
|
{emptySlots.map((slot) => (
|
|
<button
|
|
type="button"
|
|
key={slot.key}
|
|
onClick={() => assignToSlot(entry.country.code, slot.key)}
|
|
className="rounded-md border px-2 py-1 text-xs hover:bg-primary hover:text-primary-foreground"
|
|
>
|
|
{slot.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/client/src/components/predictions-form.tsx
|
|
git commit -m "rewrite predictions form as tap-to-assign with 4 ranked slots"
|
|
```
|
|
|
|
### Task 14: Update player list with prediction checkmark
|
|
|
|
**Files:**
|
|
- Modify: `packages/client/src/components/player-list.tsx`
|
|
|
|
- [ ] **Step 1: Add predictionSubmitted prop and render checkmarks**
|
|
|
|
Replace the entire file:
|
|
|
|
```tsx
|
|
import { Badge } from "@/components/ui/badge"
|
|
import type { Player } from "@celebrate-esc/shared"
|
|
|
|
interface PlayerListProps {
|
|
players: Player[]
|
|
mySessionId: string | null
|
|
predictionSubmitted?: Record<string, boolean>
|
|
}
|
|
|
|
export function PlayerList({ players, mySessionId, predictionSubmitted }: PlayerListProps) {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<h3 className="text-sm font-medium text-muted-foreground">Players ({players.length})</h3>
|
|
<ul className="flex flex-col gap-1">
|
|
{players.map((player) => (
|
|
<li key={player.id} className="flex items-center gap-2">
|
|
<span
|
|
className={`h-2 w-2 rounded-full ${player.connected ? "bg-green-500" : "bg-muted"}`}
|
|
/>
|
|
<span className={player.sessionId === mySessionId ? "font-bold" : ""}>
|
|
{player.displayName}
|
|
</span>
|
|
{player.isHost && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Host
|
|
</Badge>
|
|
)}
|
|
{player.sessionId === mySessionId && (
|
|
<span className="text-xs text-muted-foreground">(you)</span>
|
|
)}
|
|
{predictionSubmitted?.[player.id] && (
|
|
<span className="text-green-600" title="Prediction submitted">✓</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/client/src/components/player-list.tsx
|
|
git commit -m "add prediction submission checkmark to player list"
|
|
```
|
|
|
|
### Task 15: Update room header with new act labels
|
|
|
|
**Files:**
|
|
- Modify: `packages/client/src/components/room-header.tsx`
|
|
|
|
- [ ] **Step 1: Use ACT_LABELS from shared constants**
|
|
|
|
Replace the entire file:
|
|
|
|
```tsx
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { ACT_LABELS } from "@celebrate-esc/shared"
|
|
import type { Act } from "@celebrate-esc/shared"
|
|
|
|
interface RoomHeaderProps {
|
|
roomCode: string
|
|
currentAct: Act
|
|
connectionStatus: "disconnected" | "connecting" | "connected"
|
|
}
|
|
|
|
export function RoomHeader({ roomCode, currentAct, connectionStatus }: RoomHeaderProps) {
|
|
return (
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-mono text-2xl font-bold tracking-widest">{roomCode}</span>
|
|
<Badge variant="outline">{ACT_LABELS[currentAct]}</Badge>
|
|
</div>
|
|
<span
|
|
className={`h-2 w-2 rounded-full ${
|
|
connectionStatus === "connected"
|
|
? "bg-green-500"
|
|
: connectionStatus === "connecting"
|
|
? "bg-yellow-500"
|
|
: "bg-red-500"
|
|
}`}
|
|
title={connectionStatus}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add packages/client/src/components/room-header.tsx
|
|
git commit -m "use ACT_LABELS from shared constants in room header"
|
|
```
|
|
|
|
### Task 16: Update routes — remove dish UI, update act references
|
|
|
|
**Files:**
|
|
- Modify: `packages/client/src/routes/play.$roomCode.tsx`
|
|
- Modify: `packages/client/src/routes/host.$roomCode.tsx`
|
|
- Modify: `packages/client/src/routes/display.$roomCode.tsx`
|
|
- Delete: `packages/client/src/components/dish-list.tsx`
|
|
- Delete: `packages/client/src/components/dish-host.tsx`
|
|
- Delete: `packages/client/src/components/dish-results.tsx`
|
|
|
|
- [ ] **Step 1: Rewrite play.$roomCode.tsx**
|
|
|
|
Replace the entire file:
|
|
|
|
```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 { PredictionsForm } from "@/components/predictions-form"
|
|
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, gameState } = useRoomStore()
|
|
const joinSentRef = useRef(false)
|
|
const [manualName, setManualName] = useState("")
|
|
|
|
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 (
|
|
<div className="flex min-h-screen items-center justify-center">
|
|
<p className="text-muted-foreground">
|
|
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) {
|
|
return (
|
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
|
|
<h2 className="text-xl font-bold">Join Room {roomCode}</h2>
|
|
<Input
|
|
placeholder="Your name"
|
|
value={manualName}
|
|
onChange={(e) => setManualName(e.target.value)}
|
|
maxLength={20}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && manualName.trim()) {
|
|
joinSentRef.current = true
|
|
send({ type: "join_room", displayName: manualName.trim() })
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
onClick={() => {
|
|
if (manualName.trim()) {
|
|
joinSentRef.current = true
|
|
send({ type: "join_room", displayName: manualName.trim() })
|
|
}
|
|
}}
|
|
disabled={!manualName.trim()}
|
|
>
|
|
Join
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!mySessionId) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center">
|
|
<p className="text-muted-foreground">Joining room...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col">
|
|
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
|
|
<div className="flex-1 p-4">
|
|
{room.currentAct === "lobby" && !gameState && (
|
|
<div className="flex flex-col items-center gap-4 py-8">
|
|
<p className="text-lg text-muted-foreground">Waiting for the host to start...</p>
|
|
</div>
|
|
)}
|
|
|
|
{gameState && room.currentAct !== "ended" && (
|
|
<div className="flex flex-col gap-4">
|
|
<PredictionsForm
|
|
entries={gameState.lineup.entries}
|
|
existingPrediction={gameState.myPrediction}
|
|
locked={gameState.predictionsLocked}
|
|
onSubmit={(prediction) =>
|
|
send({ type: "submit_prediction", ...prediction })
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{room.currentAct === "ended" && (
|
|
<div className="flex flex-col items-center gap-4 py-8">
|
|
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
|
|
</div>
|
|
)}
|
|
|
|
<PlayerList
|
|
players={room.players}
|
|
mySessionId={mySessionId}
|
|
predictionSubmitted={gameState?.predictionSubmitted}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Rewrite host.$roomCode.tsx**
|
|
|
|
Replace the entire file:
|
|
|
|
```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 { PredictionsForm } from "@/components/predictions-form"
|
|
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<Record<Act, string>> = {
|
|
lobby: "Start Pre-Show",
|
|
"pre-show": "Start Live Event",
|
|
"live-event": "Start Scoring",
|
|
scoring: "End Party",
|
|
}
|
|
|
|
function HostView() {
|
|
const { roomCode } = Route.useParams()
|
|
const { send } = useWebSocket(roomCode)
|
|
const { room, mySessionId, connectionStatus, gameState } = useRoomStore()
|
|
|
|
if (!room) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center">
|
|
<p className="text-muted-foreground">
|
|
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col">
|
|
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
|
|
<Tabs defaultValue="host" className="flex-1">
|
|
<TabsList className="w-full rounded-none">
|
|
<TabsTrigger value="play" className="flex-1">
|
|
Play
|
|
</TabsTrigger>
|
|
<TabsTrigger value="host" className="flex-1">
|
|
Host
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="play" className="p-4">
|
|
{gameState && (room.currentAct === "lobby" || room.currentAct === "pre-show") && (
|
|
<div className="flex flex-col gap-4">
|
|
<PredictionsForm
|
|
entries={gameState.lineup.entries}
|
|
existingPrediction={gameState.myPrediction}
|
|
locked={gameState.predictionsLocked}
|
|
onSubmit={(prediction) =>
|
|
send({ type: "submit_prediction", ...prediction })
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
<PlayerList
|
|
players={room.players}
|
|
mySessionId={mySessionId}
|
|
predictionSubmitted={gameState?.predictionSubmitted}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="host" className="p-4">
|
|
<div className="flex flex-col gap-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Room Controls</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-3">
|
|
{room.currentAct !== "ended" && (
|
|
<Button onClick={() => send({ type: "advance_act" })} className="w-full">
|
|
{nextActLabels[room.currentAct] ?? "Next"}
|
|
</Button>
|
|
)}
|
|
{room.currentAct !== "ended" && (
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => send({ type: "end_room" })}
|
|
className="w-full"
|
|
>
|
|
End Party
|
|
</Button>
|
|
)}
|
|
{room.currentAct === "ended" && (
|
|
<p className="text-center text-muted-foreground">
|
|
The party has ended. Thanks for playing!
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
<PlayerList
|
|
players={room.players}
|
|
mySessionId={mySessionId}
|
|
predictionSubmitted={gameState?.predictionSubmitted}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Rewrite display.$roomCode.tsx — remove dishes, add copy-to-clipboard**
|
|
|
|
Replace the entire file:
|
|
|
|
```tsx
|
|
import { 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 { ACT_LABELS } from "@celebrate-esc/shared"
|
|
|
|
export const Route = createFileRoute("/display/$roomCode")({
|
|
component: DisplayView,
|
|
})
|
|
|
|
function DisplayView() {
|
|
const { roomCode } = Route.useParams()
|
|
useWebSocket(roomCode)
|
|
const { room, connectionStatus, gameState } = useRoomStore()
|
|
|
|
if (!room) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center">
|
|
<p className="text-muted-foreground">
|
|
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col">
|
|
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-8 p-8">
|
|
{room.currentAct === "lobby" && <LobbyDisplay roomCode={roomCode} />}
|
|
|
|
{room.currentAct === "pre-show" && gameState && (
|
|
<div className="flex flex-col items-center gap-4 py-12">
|
|
<p className="text-2xl text-muted-foreground">Pre-Show — Predictions</p>
|
|
<p className="text-lg text-muted-foreground">
|
|
{Object.values(gameState.predictionSubmitted).filter(Boolean).length} / {Object.keys(gameState.predictionSubmitted).length} predictions submitted
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{room.currentAct !== "lobby" && room.currentAct !== "ended" && room.currentAct !== "pre-show" && (
|
|
<div className="flex flex-col items-center gap-4 py-12">
|
|
<p className="text-2xl text-muted-foreground">{ACT_LABELS[room.currentAct]}</p>
|
|
</div>
|
|
)}
|
|
|
|
{room.currentAct === "ended" && (
|
|
<div className="flex flex-col items-center gap-4 py-12">
|
|
<p className="text-2xl text-muted-foreground">The party has ended. Thanks for playing!</p>
|
|
</div>
|
|
)}
|
|
|
|
<PlayerList
|
|
players={room.players}
|
|
mySessionId={null}
|
|
predictionSubmitted={gameState?.predictionSubmitted}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LobbyDisplay({ roomCode }: { roomCode: string }) {
|
|
const [copied, setCopied] = useState(false)
|
|
const base = import.meta.env.BASE_URL.replace(/\/$/, "")
|
|
const joinUrl = `${window.location.origin}${base}/play/${roomCode}`
|
|
|
|
function copyCode() {
|
|
navigator.clipboard.writeText(roomCode).then(() => {
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-6">
|
|
<h2 className="text-2xl text-muted-foreground">Join the party!</h2>
|
|
<button
|
|
type="button"
|
|
onClick={copyCode}
|
|
className="cursor-pointer rounded-lg border-4 border-dashed border-muted p-8 transition-colors hover:border-primary/50"
|
|
title="Click to copy room code"
|
|
>
|
|
<span className="font-mono text-8xl font-bold tracking-[0.3em]">{roomCode}</span>
|
|
</button>
|
|
<p className="text-muted-foreground">
|
|
{copied ? (
|
|
<span className="font-medium text-green-600">Copied!</span>
|
|
) : (
|
|
<>Tap the code to copy</>
|
|
)}
|
|
</p>
|
|
<p className="text-muted-foreground">
|
|
Go to <span className="font-mono font-medium">{joinUrl}</span>
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Delete dish components**
|
|
|
|
```bash
|
|
rm packages/client/src/components/dish-list.tsx
|
|
rm packages/client/src/components/dish-host.tsx
|
|
rm packages/client/src/components/dish-results.tsx
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add -u packages/client/src/components/dish-list.tsx packages/client/src/components/dish-host.tsx packages/client/src/components/dish-results.tsx
|
|
git add packages/client/src/routes/play.\$roomCode.tsx packages/client/src/routes/host.\$roomCode.tsx packages/client/src/routes/display.\$roomCode.tsx
|
|
git commit -m "update routes: remove dish UI, update act refs, add copy-to-clipboard on lobby display"
|
|
```
|
|
|
|
### Task 17: Final verification
|
|
|
|
- [ ] **Step 1: Run all server tests**
|
|
|
|
```bash
|
|
cd packages/server && bun test
|
|
```
|
|
|
|
Expected: All tests pass (lineup tests updated for 2025, prediction tests use new 4-pick model, no dish tests remain).
|
|
|
|
- [ ] **Step 2: Build the client**
|
|
|
|
```bash
|
|
cd packages/client && bun run build
|
|
```
|
|
|
|
Expected: Build succeeds with no errors. No imports of deleted dish components remain.
|
|
|
|
- [ ] **Step 3: Build the shared package**
|
|
|
|
```bash
|
|
cd packages/shared && bun run build
|
|
```
|
|
|
|
Expected: Build succeeds.
|
|
|
|
- [ ] **Step 4: Commit any remaining fixes**
|
|
|
|
If any build/test issues were discovered and fixed, commit them now.
|