Compare commits

...

40 Commits

Author SHA1 Message Date
felixfoertsch 6f1a63e4c9 fix stale act names in room-service.ts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:54:18 +01:00
felixfoertsch 4516d3743b delete dish components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:46:57 +01:00
felixfoertsch 0561f9350b update routes: remove dish UI, update act refs, add copy-to-clipboard on lobby display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:43:58 +01:00
felixfoertsch 42f032f67c use ACT_LABELS from shared constants in room header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:41:49 +01:00
felixfoertsch c49b41c64e add prediction submission checkmark to player list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:40:23 +01:00
felixfoertsch 4489c774e5 rewrite predictions form as tap-to-assign with 4 ranked slots
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:39:47 +01:00
felixfoertsch f9e01f18fd simplify room store, remove dish message handlers from websocket hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:38:53 +01:00
felixfoertsch aaee0f6b0d fix room manager test: update act names to match new constants
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:20:33 +01:00
felixfoertsch ae88d0ad59 rewrite game manager tests for new prediction model
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:30:36 +01:00
felixfoertsch d61d5dfa69 replace getPlayerLookup with getAllPlayerIds in room manager
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:14:28 +01:00
felixfoertsch 4932b47833 update WS handler: remove dish handlers, broadcast prediction checkmarks, lock on live-event
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:13:36 +01:00
felixfoertsch 19bbd225b2 simplify game service: remove dish persistence, update prediction columns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:12:43 +01:00
felixfoertsch 2ba74a8773 update DB schema: rename acts, update prediction columns, remove dish/jury/bingo/quiz tables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:12:21 +01:00
felixfoertsch 15d28ef053 rewrite game manager: ordered predictions, predictionSubmitted, remove dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:10:35 +01:00
felixfoertsch 518354ae75 replace esc-2026 country-only data with esc-2025 full entries (flag, artist, song)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:07:06 +01:00
felixfoertsch 5a429eb798 remove dish WS messages, update prediction message to first/second/third/last
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:05:49 +01:00
felixfoertsch 2edffdd7f9 rewrite game types: entry model with flag/artist/song, ordered predictions, remove dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:05:25 +01:00
felixfoertsch eed14f863c update acts to pre-show/live-event/scoring, add ACT_LABELS, remove unused constants
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:03:17 +01:00
felixfoertsch 08aa68d847 add implementation plan for issue #1 fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:54:41 +01:00
felixfoertsch 1d11d9becd fix spec review issues: add missing files, clarify DB migration, use Zod schemas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:05:42 +01:00
felixfoertsch 8a296afd0d add design spec for issue #1 fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:03:06 +01:00
felixfoertsch 1d16badba5 add plan docs for foundation + act1 games 2026-03-12 12:29:32 +01:00
felixfoertsch d3b61e3735 rename celebrate-esc to esc in deploy script, server log 2026-03-12 12:29:31 +01:00
felixfoertsch 883b109dad fix WS handler test to drain game_state messages after connect 2026-03-12 12:26:04 +01:00
felixfoertsch 2114084234 fix host dish visibility, show correct countries to host
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:58 +01:00
felixfoertsch a587cd66c4 add game status to display view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:26 +01:00
felixfoertsch 5d527dfc8e add game UI to host view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:05 +01:00
felixfoertsch 59777a79c3 add game UI to player view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:17:29 +01:00
felixfoertsch d6b0c62646 add game manager unit tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:16:07 +01:00
felixfoertsch 448c6ee8e6 add WS handlers for predictions, dishes game messages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:15:31 +01:00
felixfoertsch 1b0348de23 add game service for DB persistence of predictions, dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:57 +01:00
felixfoertsch 7a330c173c handle game WS messages in client hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:43 +01:00
felixfoertsch 8c2d2cefd9 wire game manager into room manager
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:40 +01:00
felixfoertsch f9f5afaec9 add game manager for predictions, dishes in-memory state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:07 +01:00
felixfoertsch 63d1893d6c add dish UI components (player list, host controls, results)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:13:06 +01:00
felixfoertsch 544c27638c add game state to zustand store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:12:50 +01:00
felixfoertsch a26f050688 add predictions form component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:12:28 +01:00
felixfoertsch 22bae2aa82 add WS message types for predictions, dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:10:52 +01:00
felixfoertsch 4ee2252dde add shared game types for predictions, dishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:10:24 +01:00
felixfoertsch e619a5f1a9 add ESC 2026 country lineup data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:10:02 +01:00
28 changed files with 8415 additions and 171 deletions
+16 -16
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
# celebrate-esc deploy script — idempotent, can be re-run safely
# esc deploy script — idempotent, can be re-run safely
# Target: Uberspace 8 (serve.uber.space)
HOST="serve"
SERVICE_DIR="services/celebrate-esc"
STATIC_DIR="/var/www/virtual/serve/html/celebrate-esc"
DB_NAME="celebrate_esc"
SERVICE_DIR="services/esc"
STATIC_DIR="/var/www/virtual/serve/html/esc"
DB_NAME="esc"
PORT=3006
echo "=== celebrate-esc deploy ==="
@@ -19,7 +19,7 @@ ssh "$HOST" "createdb -h localhost -p 5433 $DB_NAME 2>/dev/null || true"
# ── 2. Build client locally ──────────────────────────────────────────
echo "→ building client..."
cd packages/client
VITE_BASE="/celebrate-esc/" bun run build
VITE_BASE="/esc/" bun run build
cd ../..
# ── 3. Sync server code ─────────────────────────────────────────────
@@ -71,10 +71,10 @@ rsync -az --delete packages/client/dist/ "$HOST:$STATIC_DIR/"
# Create .htaccess for SPA routing
ssh "$HOST" "cat > $STATIC_DIR/.htaccess << 'HTACCESS'
RewriteEngine On
RewriteBase /celebrate-esc/
RewriteBase /esc/
# Don't rewrite API requests — handled by web backend
RewriteCond %{REQUEST_URI} ^/celebrate-esc/api [NC]
RewriteCond %{REQUEST_URI} ^/esc/api [NC]
RewriteRule . - [L]
RewriteCond %{REQUEST_FILENAME} !-f
@@ -84,14 +84,14 @@ HTACCESS"
# ── 8. Create systemd service ────────────────────────────────────────
echo "→ setting up systemd service..."
ssh "$HOST" "cat > ~/.config/systemd/user/celebrate-esc.service << 'UNIT'
ssh "$HOST" "cat > ~/.config/systemd/user/esc.service << 'UNIT'
[Unit]
Description=celebrate-esc API server
Description=esc API server
After=postgresql.service
[Service]
Type=simple
WorkingDirectory=%h/services/celebrate-esc/server
WorkingDirectory=%h/services/esc/server
ExecStart=/usr/bin/bun run --env-file=../.env src/index.ts
Restart=on-failure
RestartSec=5
@@ -101,18 +101,18 @@ WantedBy=default.target
UNIT
systemctl --user daemon-reload
systemctl --user enable celebrate-esc.service
systemctl --user restart celebrate-esc.service"
systemctl --user enable esc.service
systemctl --user restart esc.service"
# ── 9. Set up web backend routing ────────────────────────────────────
echo "→ configuring web backend routing..."
ssh "$HOST" "uberspace web backend add /celebrate-esc/api PORT $PORT --remove-prefix --force 2>/dev/null || true"
ssh "$HOST" "uberspace web backend add /esc/api PORT $PORT --remove-prefix --force 2>/dev/null || true"
# ── 10. Verify ────────────────────────────────────────────────────────
echo "→ verifying deployment..."
sleep 2
ssh "$HOST" "systemctl --user status celebrate-esc.service --no-pager | head -5"
ssh "$HOST" "systemctl --user status esc.service --no-pager | head -5"
echo ""
echo "=== deploy complete ==="
echo "Frontend: https://serve.uber.space/celebrate-esc/"
echo "API: https://serve.uber.space/celebrate-esc/api/health"
echo "Frontend: https://serve.uber.space/esc/"
echo "API: https://serve.uber.space/esc/api/health"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
# Issue #1 Fixes — Design Spec
**Goal:** Address all items in Gitea issue #1 — rework predictions to use full ESC entries with tap-to-assign UI, remove Dish of the Nation, rename acts, add player submission indicators, and make lobby code copyable.
**Source:** https://git.felixfoertsch.de/felixfoertsch/esc/issues/1
---
## 1. Entry Data Model
Replace the current country-only lineup with full ESC entries (shown as Zod schemas, matching codebase conventions):
```ts
const entrySchema = z.object({
country: z.object({ code: z.string(), name: z.string(), flag: z.string() }),
artist: z.string(),
song: z.string(),
})
const lineupSchema = z.object({
year: z.number(),
entries: z.array(entrySchema),
})
```
Note: The existing `countrySchema` gains a `flag` field — this is a breaking change to the country object shape throughout the codebase.
The data file changes from `esc-2026.json` to `esc-2025.json` using real ESC 2025 entries for testing. Each entry includes the flag emoji in the data file.
Display format everywhere: `🇩🇪 Abor — Süden`
## 2. Prediction Model
Replace the current `{ predictedWinner, top3[], nulPointsPick }` with 4 ordered picks:
```ts
type Prediction = {
playerId: string
first: string // country code
second: string // country code
third: string // country code
last: string // country code
}
```
**Validation:** All 4 picks must be distinct country codes from the lineup.
**Scoring model** (for future implementation):
- Any of 1st/2nd/3rd picks landing in the actual top 3 → points
- 1st pick matching actual winner → bonus points
- Last pick matching actual last place → bonus points
## 3. Prediction UI — Tap-to-Assign
**Top section — 4 slot cards:**
- "1st Place", "2nd Place", "3rd Place", "Last Place"
- Empty slots show placeholder text
- Filled slots show the entry (flag + artist + song) with a tap-to-remove action
**Bottom section — scrollable entry list:**
- All entries from the lineup
- Already-assigned entries are dimmed/disabled
- Tapping an unassigned entry shows a popover with only unfilled slot options (1st/2nd/3rd/Last)
- After selecting a slot, the entry fills that slot, the popover closes, and the entry dims in the list
**Submit button:** Appears when all 4 slots are filled. After submission or when predictions are locked, the UI becomes read-only showing assigned entries in their slots.
**Locked state:** When advancing past Pre-Show, predictions lock. The form shows the player's submitted picks (or "Not submitted" if they missed the window).
## 4. Remove Dish of the Nation
Strip the entire Dish of the Nation feature:
**Server:**
- Remove dish WS message handlers from `handler.ts`
- Remove dish methods from `GameManager`
- Remove dish persistence from `GameService`
- Remove dish DB tables (`dishes`, `dish_guesses`) from schema
**Client:**
- Delete `dish-list.tsx`, `dish-host.tsx`, `dish-results.tsx`
- Remove dish state slices and actions from `room-store.ts`
- Remove dish WS message handlers from `use-websocket.ts`
- Remove dish UI from routes (`play`, `host`, `display`)
**Shared:**
- Remove dish schemas from `game-types.ts`
- Remove dish WS message types from `ws-messages.ts`
## 5. Player List — Prediction Checkmark
Add prediction submission status to the game state broadcast:
- Shared: Add `predictionSubmitted: Map<playerId, boolean>` (or equivalent) to `gameStateSchema` in `game-types.ts`. This lives on the game state, not the player schema, since it's game-specific data.
- Server: When building game state in `GameManager.getGameStateForPlayer()` / `getGameStateForDisplay()`, include which players have submitted predictions.
- Client: `player-list.tsx` reads from game state and renders a checkmark icon (✓) next to player names that have submitted predictions.
- Visible on all views (play, host, display).
## 6. Acts Naming
Rename internal act identifiers and add display names:
```ts
const ACTS = ["lobby", "pre-show", "live-event", "scoring", "ended"] as const
```
| Internal ID | Display Name | Timing Intent |
|----------------|---------------|----------------------------------|
| `lobby` | Lobby | Waiting room, players join |
| `pre-show` | Pre-Show | Before broadcast, predictions |
| `live-event` | Live Event | During broadcast |
| `scoring` | Scoring | After results, leaderboard |
| `ended` | Ended | Party over |
Host control buttons: "Start Pre-Show", "Start Live Event", "Start Scoring", "End Party".
Predictions lock when advancing from Pre-Show to Live Event (previously act1 → act2).
**DB migration:** The Postgres `actEnum` must be updated from `["lobby", "act1", "act2", "act3", "ended"]` to `["lobby", "pre-show", "live-event", "scoring", "ended"]`. Since there is no production data to preserve, drop and recreate the enum (via Drizzle `push` or a migration). The `predictions` table columns also change from `predictedWinner/top3/nulPointsPick` to `first/second/third/last` — same approach, drop and recreate.
## 7. Lobby Code — Copy to Clipboard
On the display view (and anywhere the room code is shown prominently):
- Wrap the room code in a tappable/clickable element
- On click: `navigator.clipboard.writeText(roomCode)`
- Show brief "Copied!" feedback (tooltip or temporary text swap)
- Style to indicate interactivity (cursor pointer, subtle hover state)
## Data Flow Changes
### Prediction Flow (updated)
1. Client taps entry → selects slot → slot fills in UI
2. Client fills all 4 slots → submits `submit_prediction` with `{ first, second, third, last }`
3. Server validates: all 4 distinct, all valid country codes
4. Server stores in GameManager, persists to DB
5. Server broadcasts updated game state (includes `hasSubmittedPrediction` per player)
6. All clients update player list checkmarks
### Act Progression (updated names)
```
lobby → pre-show → live-event → scoring → ended
```
Predictions lock on `pre-show → live-event` transition.
## Files Affected
### Modified
- `packages/shared/src/game-types.ts` — entry/lineup schemas, prediction model, add predictionSubmitted to game state
- `packages/shared/src/ws-messages.ts` — remove dish messages, update prediction message
- `packages/shared/src/constants.ts` — act names
- `packages/server/data/` — replace `esc-2026.json` with `esc-2025.json`
- `packages/server/src/games/game-manager.ts` — remove dish logic, update prediction logic
- `packages/server/src/games/game-service.ts` — remove dish persistence, update prediction columns
- `packages/server/src/rooms/room-manager.ts` — act name references
- `packages/server/src/ws/handler.ts` — remove dish handlers, update prediction handler
- `packages/server/src/db/schema.ts` — remove dish tables, update prediction columns, update actEnum values
- `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, update game state shape
- `packages/client/src/hooks/use-websocket.ts` — remove dish 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/routes/play.$roomCode.tsx` — remove dish UI
- `packages/client/src/routes/host.$roomCode.tsx` — remove dish UI
- `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`
### Created
- `packages/server/data/esc-2025.json` — full ESC 2025 entry data
@@ -4,9 +4,10 @@ import type { Player } from "@celebrate-esc/shared"
interface PlayerListProps {
players: Player[]
mySessionId: string | null
predictionSubmitted?: Record<string, boolean>
}
export function PlayerList({ players, mySessionId }: PlayerListProps) {
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>
@@ -27,6 +28,9 @@ export function PlayerList({ players, mySessionId }: PlayerListProps) {
{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>
@@ -0,0 +1,227 @@
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 change
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>
)
}
@@ -1,4 +1,5 @@
import { Badge } from "@/components/ui/badge"
import { ACT_LABELS } from "@celebrate-esc/shared"
import type { Act } from "@celebrate-esc/shared"
interface RoomHeaderProps {
@@ -7,20 +8,12 @@ interface RoomHeaderProps {
connectionStatus: "disconnected" | "connecting" | "connected"
}
const actLabels: Record<Act, string> = {
lobby: "Lobby",
act1: "Act 1",
act2: "Act 2",
act3: "Act 3",
ended: "Ended",
}
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">{actLabels[currentAct]}</Badge>
<Badge variant="outline">{ACT_LABELS[currentAct]}</Badge>
</div>
<span
className={`h-2 w-2 rounded-full ${
+9 -2
View File
@@ -28,6 +28,8 @@ export function useWebSocket(roomCode: string) {
addPlayer,
setAct,
reset,
setGameState,
lockPredictions,
} = useRoomStore()
const send = useCallback((message: ClientMessage) => {
@@ -64,7 +66,6 @@ export function useWebSocket(roomCode: string) {
setMySessionId(msg.sessionId)
storeSession(roomCode, msg.sessionId)
} else if (sessionId) {
// Reconnected with stored session
setMySessionId(sessionId)
}
break
@@ -84,6 +85,12 @@ export function useWebSocket(roomCode: string) {
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
@@ -98,7 +105,7 @@ export function useWebSocket(roomCode: string) {
ws.close()
reset()
}
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset])
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset, setGameState, lockPredictions])
return { send }
}
@@ -1,8 +1,10 @@
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,
@@ -11,7 +13,7 @@ export const Route = createFileRoute("/display/$roomCode")({
function DisplayView() {
const { roomCode } = Route.useParams()
useWebSocket(roomCode)
const { room, connectionStatus } = useRoomStore()
const { room, connectionStatus, gameState } = useRoomStore()
if (!room) {
return (
@@ -28,29 +30,71 @@ function DisplayView() {
<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} />}
<PlayerList players={room.players} mySessionId={null} />
{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 joinUrl = `${window.location.origin}/play/${roomCode}`
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>
<div className="rounded-lg border-4 border-dashed border-muted p-8">
<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>
</div>
</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>
<p className="text-sm text-muted-foreground">or scan the QR code</p>
{/* QR code will be added in Plan 5 (polish) */}
<div className="flex h-48 w-48 items-center justify-center rounded-lg border-2 border-dashed border-muted">
<span className="text-sm text-muted-foreground">QR code</span>
</div>
</div>
)
}
+28 -8
View File
@@ -2,6 +2,7 @@ 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"
@@ -13,16 +14,16 @@ export const Route = createFileRoute("/host/$roomCode")({
})
const nextActLabels: Partial<Record<Act, string>> = {
lobby: "Start Act 1",
act1: "Start Act 2",
act2: "Start Act 3",
act3: "End Party",
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 } = useRoomStore()
const { room, mySessionId, connectionStatus, gameState } = useRoomStore()
if (!room) {
return (
@@ -47,8 +48,23 @@ function HostView() {
</TabsTrigger>
</TabsList>
<TabsContent value="play" className="p-4">
<PlayerList players={room.players} mySessionId={mySessionId} />
{/* Game UI will be added in later plans */}
{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>
)}
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</TabsContent>
<TabsContent value="host" className="p-4">
<div className="flex flex-col gap-4">
@@ -78,7 +94,11 @@ function HostView() {
)}
</CardContent>
</Card>
<PlayerList players={room.players} mySessionId={mySessionId} />
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</div>
</TabsContent>
</Tabs>
+23 -7
View File
@@ -3,6 +3,7 @@ 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"
@@ -14,11 +15,10 @@ export const Route = createFileRoute("/play/$roomCode")({
function PlayerView() {
const { roomCode } = Route.useParams()
const { send } = useWebSocket(roomCode)
const { room, mySessionId, connectionStatus } = useRoomStore()
const { room, mySessionId, connectionStatus, gameState } = 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
@@ -40,8 +40,6 @@ function PlayerView() {
)
}
// 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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
@@ -85,18 +83,36 @@ function PlayerView() {
<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" && (
{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>
)}
{/* Game UI will be added in later plans */}
<PlayerList players={room.players} mySessionId={mySessionId} />
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</div>
</div>
)
+16 -3
View File
@@ -1,10 +1,11 @@
import { create } from "zustand"
import type { RoomState, Player } from "@celebrate-esc/shared"
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
@@ -12,6 +13,8 @@ interface RoomStore {
updatePlayerConnected: (playerId: string, connected: boolean) => void
addPlayer: (player: Player) => void
setAct: (act: RoomState["currentAct"]) => void
setGameState: (gameState: GameState) => void
lockPredictions: () => void
reset: () => void
}
@@ -19,6 +22,7 @@ export const useRoomStore = create<RoomStore>((set) => ({
room: null,
mySessionId: null,
connectionStatus: "disconnected",
gameState: null,
setRoom: (room) => set({ room }),
setMySessionId: (sessionId) => set({ mySessionId: sessionId }),
@@ -38,7 +42,6 @@ export const useRoomStore = create<RoomStore>((set) => ({
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: {
@@ -54,5 +57,15 @@ export const useRoomStore = create<RoomStore>((set) => ({
return { room: { ...state.room, currentAct: act } }
}),
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected" }),
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 }),
}))
+190
View File
@@ -0,0 +1,190 @@
{
"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 With U"
},
{
"country": { "code": "BE", "name": "Belgium", "flag": "🇧🇪" },
"artist": "Red Sebastian",
"song": "Strobe Lights"
},
{
"country": { "code": "HR", "name": "Croatia", "flag": "🇭🇷" },
"artist": "Marko Bošnjak",
"song": "Poison Cake"
},
{
"country": { "code": "CY", "name": "Cyprus", "flag": "🇨🇾" },
"artist": "Theo Evan",
"song": "Shh"
},
{
"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 Shengelia",
"song": "Freedom"
},
{
"country": { "code": "DE", "name": "Germany", "flag": "🇩🇪" },
"artist": "Abor & Tynna",
"song": "Baller"
},
{
"country": { "code": "GR", "name": "Greece", "flag": "🇬🇷" },
"artist": "Klavdia",
"song": "Asteromáta"
},
{
"country": { "code": "IS", "name": "Iceland", "flag": "🇮🇸" },
"artist": "Væb",
"song": "Róa"
},
{
"country": { "code": "IE", "name": "Ireland", "flag": "🇮🇪" },
"artist": "Emmy",
"song": "Laika Party"
},
{
"country": { "code": "IL", "name": "Israel", "flag": "🇮🇱" },
"artist": "Yuval Raphael",
"song": "New Day Will Rise"
},
{
"country": { "code": "IT", "name": "Italy", "flag": "🇮🇹" },
"artist": "Lucio Corsi",
"song": "Volevo essere un duro"
},
{
"country": { "code": "LV", "name": "Latvia", "flag": "🇱🇻" },
"artist": "Tautumeitas",
"song": "Bur man laimi"
},
{
"country": { "code": "LT", "name": "Lithuania", "flag": "🇱🇹" },
"artist": "Katarsis",
"song": "Tavo akys"
},
{
"country": { "code": "LU", "name": "Luxembourg", "flag": "🇱🇺" },
"artist": "Laura Thorn",
"song": "La poupée monte le son"
},
{
"country": { "code": "MT", "name": "Malta", "flag": "🇲🇹" },
"artist": "Miriana Conte",
"song": "Serving"
},
{
"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": "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": "SM", "name": "San Marino", "flag": "🇸🇲" },
"artist": "Gabry Ponte",
"song": "Tutta l'Italia"
},
{
"country": { "code": "RS", "name": "Serbia", "flag": "🇷🇸" },
"artist": "Princ",
"song": "Mila"
},
{
"country": { "code": "SI", "name": "Slovenia", "flag": "🇸🇮" },
"artist": "Klemen",
"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 bada bastu"
},
{
"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?"
}
]
}
+7 -90
View File
@@ -1,8 +1,6 @@
import { boolean, integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
import { boolean, 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"])
export const actEnum = pgEnum("act", ["lobby", "pre-show", "live-event", "scoring", "ended"])
// ─── Room System ────────────────────────────────────────────────────
@@ -31,7 +29,7 @@ export const players = pgTable("players", {
joinedAt: timestamp("joined_at").notNull().defaultNow(),
})
// ─── Predictions (Plan 2) ──────────────────────────────────────────
// ─── Predictions ────────────────────────────────────────────────────
export const predictions = pgTable("predictions", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -41,89 +39,8 @@ export const predictions = pgTable("predictions", {
roomId: uuid("room_id")
.notNull()
.references(() => rooms.id),
predictedWinner: varchar("predicted_winner").notNull(),
top3: jsonb("top_3").notNull().$type<string[]>(),
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"),
first: varchar("first").notNull(),
second: varchar("second").notNull(),
third: varchar("third").notNull(),
last: varchar("last").notNull(),
})
+90
View File
@@ -0,0 +1,90 @@
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),
}
}
}
+29
View File
@@ -0,0 +1,29 @@
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,
})
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ 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}`)
console.log(`esc server running on http://localhost:${info.port}`)
})
injectWebSocket(server)
+20
View File
@@ -1,6 +1,7 @@
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 } from "@celebrate-esc/shared"
import { GameManager } from "../games/game-manager"
interface InternalPlayer {
id: string
@@ -18,6 +19,7 @@ interface InternalRoom {
players: Map<string, InternalPlayer> // sessionId -> player
createdAt: Date
expiresAt: Date
gameManager: GameManager
}
export class RoomManager {
@@ -44,6 +46,7 @@ export class RoomManager {
players: new Map([[sessionId, host]]),
createdAt: now,
expiresAt: new Date(now.getTime() + ROOM_EXPIRY_HOURS * 60 * 60 * 1000),
gameManager: new GameManager(),
}
this.rooms.set(code, room)
@@ -140,6 +143,23 @@ export class RoomManager {
return room?.hostSessionId === sessionId
}
getGameManager(code: string): GameManager | null {
const room = this.rooms.get(code)
return room?.gameManager ?? null
}
getAllPlayerIds(code: string): string[] {
const room = this.rooms.get(code)
if (!room) return []
return Array.from(room.players.values()).map((p) => p.id)
}
getPlayerIdBySession(code: string, sessionId: string): string | null {
const room = this.rooms.get(code)
if (!room) return null
return room.players.get(sessionId)?.id ?? null
}
/** Clear all rooms -- used in tests */
reset(): void {
this.rooms.clear()
+3 -3
View File
@@ -17,14 +17,14 @@ export class RoomService {
.values({
id: room.id,
code: room.code,
currentAct: room.currentAct as "lobby" | "act1" | "act2" | "act3" | "ended",
currentAct: room.currentAct as "lobby" | "pre-show" | "live-event" | "scoring" | "ended",
hostSessionId: room.hostSessionId,
expiresAt: room.expiresAt,
})
.onConflictDoUpdate({
target: rooms.id,
set: {
currentAct: room.currentAct as "lobby" | "act1" | "act2" | "act3" | "ended",
currentAct: room.currentAct as "lobby" | "pre-show" | "live-event" | "scoring" | "ended",
},
})
}
@@ -56,7 +56,7 @@ export class RoomService {
async updateRoomAct(roomId: string, act: string) {
await this.db
.update(rooms)
.set({ currentAct: act as "lobby" | "act1" | "act2" | "act3" | "ended" })
.set({ currentAct: act as "lobby" | "pre-show" | "live-event" | "scoring" | "ended" })
.where(eq(rooms.id, roomId))
}
+70 -4
View File
@@ -41,6 +41,41 @@ 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() {
@@ -66,7 +101,6 @@ export function registerWebSocketRoutes() {
connection = { ws, sessionId }
getConnections(roomCode).add(connection)
// If sessionId provided, attempt reconnect
if (sessionId) {
const result = roomManager.reconnectPlayer(roomCode, sessionId)
if ("error" in result) {
@@ -79,17 +113,18 @@ export function registerWebSocketRoutes() {
type: "room_state",
room: roomManager.getRoom(roomCode)!,
})
sendGameState(ws, roomCode, sessionId)
broadcast(roomCode, {
type: "player_reconnected",
playerId: result.playerId,
})
}
} else {
// Passive viewer (display) or player about to send join_room
sendTo(ws, {
type: "room_state",
room: roomManager.getRoom(roomCode)!,
})
sendDisplayGameState(ws, roomCode)
}
},
@@ -125,14 +160,13 @@ export function registerWebSocketRoutes() {
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,
})
sendGameState(ws, roomCode, result.sessionId)
// Broadcast player joined to everyone
const room = roomManager.getRoom(roomCode)!
const newPlayer = room.players.find((p) => p.sessionId === sessionId)!
broadcast(roomCode, {
@@ -155,6 +189,7 @@ export function registerWebSocketRoutes() {
type: "room_state",
room: roomManager.getRoom(roomCode)!,
})
sendGameState(ws, roomCode, msg.sessionId)
broadcast(roomCode, {
type: "player_reconnected",
playerId: result.playerId,
@@ -176,6 +211,14 @@ export function registerWebSocketRoutes() {
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
}
@@ -192,6 +235,29 @@ export function registerWebSocketRoutes() {
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
}
}
},
+103
View File
@@ -0,0 +1,103 @@
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 })
})
})
})
+1 -1
View File
@@ -96,7 +96,7 @@ describe("RoomManager", () => {
const room = manager.getRoom(code)!
const hostSession = room.hostSessionId
const expectedSequence: Act[] = ["act1", "act2", "act3", "ended"]
const expectedSequence: Act[] = ["pre-show", "live-event", "scoring", "ended"]
for (const expected of expectedSequence) {
const result = manager.advanceAct(code, hostSession)
expect(result).toEqual({ newAct: expected })
+20 -6
View File
@@ -21,6 +21,20 @@ function waitForMessage(ws: WebSocket): Promise<unknown> {
})
}
/** Consume messages until one with the given type arrives */
function waitForMessageType(ws: WebSocket, type: string): Promise<unknown> {
return new Promise((resolve) => {
function handler(event: MessageEvent) {
const msg = JSON.parse(event.data as string) as { type: string }
if (msg.type === type) {
ws.removeEventListener("message", handler)
resolve(msg)
}
}
ws.addEventListener("message", handler)
})
}
function waitForOpen(ws: WebSocket): Promise<void> {
return new Promise((resolve) => {
if (ws.readyState === WebSocket.OPEN) {
@@ -75,19 +89,19 @@ describe("WebSocket handler", () => {
})
const { data } = (await res.json()) as { data: { code: string; sessionId: string } }
// Connect host
// Connect host — consumes room_state + game_state from onOpen
const hostWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}?sessionId=${data.sessionId}`)
await waitForOpen(hostWs)
await waitForMessage(hostWs) // room_state
await waitForMessageType(hostWs, "game_state") // drain initial messages
// Connect player (no sessionId)
// Connect player (no sessionId — passive until join_room)
const playerWs = new WebSocket(`ws://localhost:${port}/ws/${data.code}`)
await waitForOpen(playerWs)
await waitForMessage(playerWs) // initial room_state
await waitForMessageType(playerWs, "game_state") // drain initial messages
// Set up listeners BEFORE sending to avoid race conditions
const playerMsgPromise = waitForMessage(playerWs)
const hostMsgPromise = waitForMessage(hostWs)
const playerMsgPromise = waitForMessageType(playerWs, "room_state")
const hostMsgPromise = waitForMessageType(hostWs, "player_joined")
// Player sends join_room
playerWs.send(JSON.stringify({ type: "join_room", displayName: "Player 1" }))
+8 -8
View File
@@ -5,13 +5,13 @@ 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 const ACTS = ["lobby", "pre-show", "live-event", "scoring", "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
export const ACT_LABELS: Record<Act, string> = {
lobby: "Lobby",
"pre-show": "Pre-Show",
"live-event": "Live Event",
scoring: "Scoring",
ended: "Ended",
}
+49
View File
@@ -0,0 +1,49 @@
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>
+1
View File
@@ -1,3 +1,4 @@
export * from "./constants"
export * from "./game-types"
export * from "./room-types"
export * from "./ws-messages"
+21 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod"
import { ACTS } from "./constants"
import { gameStateSchema } from "./game-types"
import { playerSchema, roomStateSchema } from "./room-types"
// ─── Client → Server ───────────────────────────────────────────────
@@ -22,12 +23,20 @@ export const endRoomMessage = z.object({
type: z.literal("end_room"),
})
/** Union of all client → server messages (room system only — games add more) */
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>
@@ -69,7 +78,15 @@ export const errorMessage = z.object({
message: z.string(),
})
/** Union of all server → client messages (room system only) */
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,
@@ -78,6 +95,8 @@ export const serverMessage = z.discriminatedUnion("type", [
actChangedMessage,
roomEndedMessage,
errorMessage,
gameStateMessage,
predictionsLockedMessage,
])
export type ServerMessage = z.infer<typeof serverMessage>