Compare commits
40 Commits
0cd1d9d2f6
...
6f1a63e4c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f1a63e4c9 | |||
| 4516d3743b | |||
| 0561f9350b | |||
| 42f032f67c | |||
| c49b41c64e | |||
| 4489c774e5 | |||
| f9e01f18fd | |||
| aaee0f6b0d | |||
| ae88d0ad59 | |||
| d61d5dfa69 | |||
| 4932b47833 | |||
| 19bbd225b2 | |||
| 2ba74a8773 | |||
| 15d28ef053 | |||
| 518354ae75 | |||
| 5a429eb798 | |||
| 2edffdd7f9 | |||
| eed14f863c | |||
| 08aa68d847 | |||
| 1d11d9becd | |||
| 8a296afd0d | |||
| 1d16badba5 | |||
| d3b61e3735 | |||
| 883b109dad | |||
| 2114084234 | |||
| a587cd66c4 | |||
| 5d527dfc8e | |||
| 59777a79c3 | |||
| d6b0c62646 | |||
| 448c6ee8e6 | |||
| 1b0348de23 | |||
| 7a330c173c | |||
| 8c2d2cefd9 | |||
| f9f5afaec9 | |||
| 63d1893d6c | |||
| 544c27638c | |||
| a26f050688 | |||
| 22bae2aa82 | |||
| 4ee2252dde | |||
| e619a5f1a9 |
@@ -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 ${
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./constants"
|
||||
export * from "./game-types"
|
||||
export * from "./room-types"
|
||||
export * from "./ws-messages"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user