Compare commits

...

20 Commits

Author SHA1 Message Date
f22dba6134 add jury rounds, jury votes, bingo cards DB tables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:02:02 +01:00
60a5962519 integrate jury display, bingo announcements, leaderboard in display route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:02:01 +01:00
a71308f6f0 integrate jury controls, bingo, leaderboard in host route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:02:00 +01:00
611a1bf732 integrate jury voting, bingo tabs in player route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:01:55 +01:00
c768d7340a add leaderboard component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:50:33 +01:00
f6223ae9fa add bingo display component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:50:33 +01:00
7f5dba6e03 add bingo card player component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:50:31 +01:00
8ee9295b4e add jury display component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:28:35 +01:00
094fd1feeb add jury host controls component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:28:34 +01:00
d247c2519e add jury voting player component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:28:33 +01:00
302f2e14c0 handle new jury, bingo WS message types in client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:28:25 +01:00
ceba5521dc add jury voting, bingo WS message handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:51:32 +01:00
7cb52291f3 add getPlayerDisplayNames to RoomManager
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:50:51 +01:00
aedd3c032a extend GameManager game state with jury, bingo, leaderboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:50:43 +01:00
b79ddb9679 add bingo logic to GameManager with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:50:07 +01:00
0703364945 add jury voting logic to GameManager with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:49:17 +01:00
e48ee2ca35 add jury voting, bingo WS message types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:45:35 +01:00
9ec0225e4b add jury, bingo, leaderboard schemas to shared game types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:45:32 +01:00
c31f849de3 add bingo tropes data file (35 tropes)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:45:27 +01:00
0019024066 add Act 2 (jury voting + bingo) implementation plan 2026-03-12 19:42:49 +01:00
19 changed files with 3355 additions and 19 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
import type { BingoCard as BingoCardType } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
interface BingoCardProps {
card: BingoCardType
onTap: (tropeId: string) => void
}
export function BingoCard({ card, onTap }: BingoCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle>Bingo</CardTitle>
{card.hasBingo && (
<Badge variant="default" className="bg-green-600">
BINGO!
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-1.5">
{card.squares.map((square) => (
<button
key={square.tropeId}
type="button"
onClick={() => onTap(square.tropeId)}
className={`flex aspect-square items-center justify-center rounded-md border p-1 text-center text-xs leading-tight transition-colors ${
square.tapped
? "border-primary bg-primary/20 font-medium text-primary"
: "border-muted hover:bg-muted/50"
}`}
>
{square.label}
</button>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,18 @@
interface BingoDisplayProps {
announcements: { playerId: string; displayName: string }[]
}
export function BingoDisplay({ announcements }: BingoDisplayProps) {
if (announcements.length === 0) return null
return (
<div className="flex flex-col items-center gap-2">
<p className="text-sm font-medium text-muted-foreground">Bingo Winners</p>
{announcements.map((a, i) => (
<p key={`${a.playerId}-${i}`} className="text-lg font-bold text-green-600">
{a.displayName} got BINGO!
</p>
))}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import type { JuryRound, JuryResult } from "@celebrate-esc/shared"
interface JuryDisplayProps {
currentRound: JuryRound | null
results: JuryResult[]
}
export function JuryDisplay({ currentRound, results }: JuryDisplayProps) {
if (currentRound) {
return (
<div className="flex flex-col items-center gap-4">
<p className="text-lg text-muted-foreground">Now voting</p>
<div className="text-center">
<span className="text-6xl">{currentRound.countryFlag}</span>
<p className="mt-2 text-3xl font-bold">{currentRound.countryName}</p>
</div>
<p className="text-muted-foreground">Rate 1-12 on your phone</p>
</div>
)
}
if (results.length > 0) {
const lastResult = results[results.length - 1]!
const topRated = [...results].sort((a, b) => b.averageRating - a.averageRating)[0]!
return (
<div className="flex flex-col items-center gap-6">
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">Latest result</p>
<span className="text-4xl">{lastResult.countryFlag}</span>
<p className="mt-2 text-2xl font-bold">{lastResult.countryName}</p>
<p className="text-4xl font-bold text-primary">{lastResult.averageRating}</p>
<p className="text-sm text-muted-foreground">{lastResult.totalVotes} votes</p>
</div>
{results.length > 1 && (
<div className="rounded-lg border-2 border-primary/30 bg-primary/5 p-6 text-center">
<p className="text-lg font-medium text-muted-foreground">
And 12 points go to...
</p>
<p className="mt-2 text-3xl font-bold">
{topRated.countryFlag} {topRated.countryName}
</p>
<p className="text-2xl font-bold text-primary">
{topRated.averageRating} avg
</p>
</div>
)}
<div className="w-full max-w-md">
<p className="mb-2 text-sm font-medium text-muted-foreground">Rankings</p>
{[...results]
.sort((a, b) => b.averageRating - a.averageRating)
.map((r, i) => (
<div key={r.countryCode} className="flex items-center justify-between border-b py-1 text-sm">
<span>
<span className="mr-2 font-bold text-muted-foreground">{i + 1}</span>
{r.countryFlag} {r.countryName}
</span>
<span className="font-medium">{r.averageRating}</span>
</div>
))}
</div>
</div>
)
}
return (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">Live Event</p>
<p className="text-muted-foreground">Waiting for host to open voting...</p>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { useState } from "react"
import type { Entry, JuryRound, JuryResult } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface JuryHostProps {
entries: Entry[]
currentRound: JuryRound | null
results: JuryResult[]
onOpenVote: (countryCode: string) => void
onCloseVote: () => void
}
export function JuryHost({ entries, currentRound, results, onOpenVote, onCloseVote }: JuryHostProps) {
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const votedCountries = new Set(results.map((r) => r.countryCode))
if (currentRound) {
return (
<Card>
<CardHeader>
<CardTitle>
Voting: {currentRound.countryFlag} {currentRound.countryName}
</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={onCloseVote} variant="destructive" className="w-full">
Close Voting
</Button>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Jury Voting</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{results.length > 0 && (
<div className="mb-2">
<p className="mb-1 text-sm font-medium text-muted-foreground">
Completed ({results.length})
</p>
{results.map((r) => (
<div key={r.countryCode} className="flex items-center justify-between text-sm">
<span>{r.countryFlag} {r.countryName}</span>
<span className="text-muted-foreground">avg {r.averageRating} ({r.totalVotes} votes)</span>
</div>
))}
</div>
)}
<div className="flex flex-col gap-1">
{entries.map((entry) => {
const voted = votedCountries.has(entry.country.code)
const isSelected = selectedCountry === entry.country.code
return (
<button
key={entry.country.code}
type="button"
disabled={voted}
onClick={() => setSelectedCountry(isSelected ? null : entry.country.code)}
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
voted
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
: isSelected
? "border-primary bg-primary/5"
: "hover:bg-muted"
}`}
>
{entry.country.flag} {entry.artist} {entry.song}
</button>
)
})}
</div>
{selectedCountry && (
<Button
onClick={() => {
onOpenVote(selectedCountry)
setSelectedCountry(null)
}}
className="w-full"
>
Open Voting
</Button>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,57 @@
import { useState } from "react"
import type { JuryRound } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface JuryVotingProps {
round: JuryRound
myVote: number | null
onVote: (rating: number) => void
}
export function JuryVoting({ round, myVote, onVote }: JuryVotingProps) {
const [selectedRating, setSelectedRating] = useState<number | null>(myVote)
return (
<Card>
<CardHeader>
<CardTitle className="text-center">
<span className="text-2xl">{round.countryFlag}</span>{" "}
{round.countryName}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-center text-sm text-muted-foreground">
Rate this performance (1-12)
</p>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i + 1).map((rating) => (
<Button
key={rating}
variant={selectedRating === rating ? "default" : "outline"}
size="lg"
onClick={() => setSelectedRating(rating)}
className="text-lg font-bold"
>
{rating}
</Button>
))}
</div>
<Button
onClick={() => {
if (selectedRating) onVote(selectedRating)
}}
disabled={!selectedRating}
className="w-full"
>
{myVote ? "Update Vote" : "Submit Vote"}
</Button>
{myVote && (
<p className="text-center text-sm text-muted-foreground">
Your vote: {myVote}
</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,37 @@
import type { LeaderboardEntry } from "@celebrate-esc/shared"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface LeaderboardProps {
entries: LeaderboardEntry[]
}
export function Leaderboard({ entries }: LeaderboardProps) {
if (entries.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle>Leaderboard</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-1">
{entries.map((entry, i) => (
<div key={entry.playerId} className="flex items-center justify-between border-b py-1.5 last:border-0">
<div className="flex items-center gap-2">
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
{i + 1}
</span>
<span className="text-sm font-medium">{entry.displayName}</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title="Jury points">J:{entry.juryPoints}</span>
<span title="Bingo points">B:{entry.bingoPoints}</span>
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -91,6 +91,11 @@ export function useWebSocket(roomCode: string) {
case "predictions_locked":
lockPredictions()
break
case "jury_vote_opened":
case "jury_vote_closed":
case "bingo_announced":
// State updates arrive via game_state; these are supplementary signals
break
case "error":
console.error("Server error:", msg.message)
break

View File

@@ -3,8 +3,10 @@ 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 { JuryDisplay } from "@/components/jury-display"
import { BingoDisplay } from "@/components/bingo-display"
import { Leaderboard } from "@/components/leaderboard"
import { RoomHeader } from "@/components/room-header"
import { ACT_LABELS } from "@celebrate-esc/shared"
export const Route = createFileRoute("/display/$roomCode")({
component: DisplayView,
@@ -40,15 +42,28 @@ function DisplayView() {
</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>
{room.currentAct === "live-event" && gameState && (
<div className="flex flex-col items-center gap-8">
<JuryDisplay
currentRound={gameState.currentJuryRound}
results={gameState.juryResults}
/>
<BingoDisplay announcements={gameState.bingoAnnouncements} />
<Leaderboard entries={gameState.leaderboard} />
</div>
)}
{room.currentAct === "scoring" && gameState && (
<div className="flex flex-col items-center gap-8">
<p className="text-2xl text-muted-foreground">Scoring</p>
<Leaderboard entries={gameState.leaderboard} />
</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>
{gameState && <Leaderboard entries={gameState.leaderboard} />}
</div>
)}

View File

@@ -3,6 +3,10 @@ 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 { JuryHost } from "@/components/jury-host"
import { JuryVoting } from "@/components/jury-voting"
import { BingoCard } from "@/components/bingo-card"
import { Leaderboard } from "@/components/leaderboard"
import { RoomHeader } from "@/components/room-header"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -48,7 +52,7 @@ function HostView() {
</TabsTrigger>
</TabsList>
<TabsContent value="play" className="p-4">
{gameState && room.currentAct !== "ended" && (
{gameState && (room.currentAct === "lobby" || room.currentAct === "pre-show") && (
<div className="flex flex-col gap-4">
<PredictionsForm
entries={gameState.lineup.entries}
@@ -60,6 +64,45 @@ function HostView() {
/>
</div>
)}
{gameState && room.currentAct === "live-event" && (
<Tabs defaultValue="jury">
<TabsList className="w-full">
<TabsTrigger value="jury" className="flex-1">Jury</TabsTrigger>
<TabsTrigger value="bingo" className="flex-1">Bingo</TabsTrigger>
</TabsList>
<TabsContent value="jury" className="mt-4">
{gameState.currentJuryRound ? (
<JuryVoting
round={gameState.currentJuryRound}
myVote={gameState.myJuryVote}
onVote={(rating) => send({ type: "submit_jury_vote", rating })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
Waiting for host to open voting...
</div>
)}
</TabsContent>
<TabsContent value="bingo" className="mt-4">
{gameState.myBingoCard ? (
<BingoCard
card={gameState.myBingoCard}
onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
No bingo card yet
</div>
)}
</TabsContent>
</Tabs>
)}
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<Leaderboard entries={gameState.leaderboard} />
)}
<PlayerList
players={room.players}
mySessionId={mySessionId}
@@ -92,6 +135,18 @@ function HostView() {
The party has ended. Thanks for playing!
</p>
)}
{room.currentAct === "live-event" && gameState && (
<JuryHost
entries={gameState.lineup.entries}
currentRound={gameState.currentJuryRound}
results={gameState.juryResults}
onOpenVote={(countryCode) => send({ type: "open_jury_vote", countryCode })}
onCloseVote={() => send({ type: "close_jury_vote" })}
/>
)}
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<Leaderboard entries={gameState.leaderboard} />
)}
</CardContent>
</Card>
<PlayerList

View File

@@ -4,9 +4,13 @@ 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 { JuryVoting } from "@/components/jury-voting"
import { BingoCard } from "@/components/bingo-card"
import { Leaderboard } from "@/components/leaderboard"
import { RoomHeader } from "@/components/room-header"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export const Route = createFileRoute("/play/$roomCode")({
component: PlayerView,
@@ -83,13 +87,7 @@ 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" && !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" && (
{gameState && (room.currentAct === "lobby" || room.currentAct === "pre-show") && (
<div className="flex flex-col gap-4">
<PredictionsForm
entries={gameState.lineup.entries}
@@ -102,9 +100,48 @@ function PlayerView() {
</div>
)}
{gameState && room.currentAct === "live-event" && (
<Tabs defaultValue="jury" className="flex-1">
<TabsList className="w-full">
<TabsTrigger value="jury" className="flex-1">Jury</TabsTrigger>
<TabsTrigger value="bingo" className="flex-1">Bingo</TabsTrigger>
</TabsList>
<TabsContent value="jury" className="mt-4">
{gameState.currentJuryRound ? (
<JuryVoting
round={gameState.currentJuryRound}
myVote={gameState.myJuryVote}
onVote={(rating) => send({ type: "submit_jury_vote", rating })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
Waiting for host to open voting...
</div>
)}
</TabsContent>
<TabsContent value="bingo" className="mt-4">
{gameState.myBingoCard ? (
<BingoCard
card={gameState.myBingoCard}
onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
/>
) : (
<div className="py-8 text-center text-muted-foreground">
No bingo card yet
</div>
)}
</TabsContent>
</Tabs>
)}
{gameState && room.currentAct === "scoring" && (
<Leaderboard entries={gameState.leaderboard} />
)}
{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>
{gameState && <Leaderboard entries={gameState.leaderboard} />}
</div>
)}

View File

@@ -0,0 +1,37 @@
[
{ "id": "key-change", "label": "Key change" },
{ "id": "pyrotechnics", "label": "Pyrotechnics on stage" },
{ "id": "wind-machine", "label": "Wind machine" },
{ "id": "costume-change", "label": "Costume change mid-song" },
{ "id": "barefoot", "label": "Performer is barefoot" },
{ "id": "rain-on-stage", "label": "Rain/water effect on stage" },
{ "id": "flag-waving", "label": "Flag waving in audience" },
{ "id": "crowd-singalong", "label": "Crowd sings along" },
{ "id": "heart-hands", "label": "Heart-shaped hand gesture" },
{ "id": "political-message", "label": "Political/peace message" },
{ "id": "prop-on-stage", "label": "Unusual prop on stage" },
{ "id": "dancer-lift", "label": "Dancer does a lift" },
{ "id": "whistle-note", "label": "Whistle note / extreme high note" },
{ "id": "spoken-word", "label": "Spoken word section" },
{ "id": "guitar-solo", "label": "Guitar solo" },
{ "id": "ethnic-instrument", "label": "Traditional/ethnic instrument" },
{ "id": "glitter-outfit", "label": "Glitter/sequin outfit" },
{ "id": "backup-choir", "label": "Dramatic backup choir" },
{ "id": "confetti", "label": "Confetti drop" },
{ "id": "led-floor", "label": "LED floor visuals" },
{ "id": "love-ballad", "label": "Slow love ballad" },
{ "id": "rap-section", "label": "Rap section in song" },
{ "id": "audience-clap", "label": "Audience clap-along" },
{ "id": "mirror-outfit", "label": "Mirror/reflective outfit" },
{ "id": "trampolines", "label": "Trampolines or acrobatics" },
{ "id": "drone-camera", "label": "Drone camera shot" },
{ "id": "language-switch", "label": "Song switches language mid-song" },
{ "id": "standing-ovation", "label": "Standing ovation" },
{ "id": "host-joke-flop", "label": "Host joke falls flat" },
{ "id": "technical-glitch", "label": "Technical glitch on screen" },
{ "id": "twelve-points", "label": "Commentator says \"douze points\"" },
{ "id": "cry-on-stage", "label": "Performer cries on stage" },
{ "id": "country-shoutout", "label": "Shoutout to their country" },
{ "id": "dramatic-pause", "label": "Dramatic pause before last note" },
{ "id": "staging-surprise", "label": "Staging surprise/reveal" }
]

View File

@@ -1,4 +1,4 @@
import { boolean, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
import { boolean, integer, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"
export const actEnum = pgEnum("act", ["lobby", "pre-show", "live-event", "scoring", "ended"])
@@ -44,3 +44,41 @@ export const predictions = pgTable("predictions", {
third: varchar("third").notNull(),
last: varchar("last").notNull(),
})
// ─── Jury Voting ────────────────────────────────────────────────────
export const juryRoundStatusEnum = pgEnum("jury_round_status", ["open", "closed"])
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 ──────────────────────────────────────────────────────────
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(),
})

View File

@@ -1,5 +1,9 @@
import type { Prediction, GameState, Lineup } from "@celebrate-esc/shared"
import type { Prediction, GameState, Lineup, JuryRound, JuryResult } from "@celebrate-esc/shared"
import lineupData from "../../data/esc-2025.json"
import scoringConfig from "../../data/scoring.json"
import tropesData from "../../data/bingo-tropes.json"
const tropes: { id: string; label: string }[] = tropesData
const lineup: Lineup = lineupData as Lineup
const countryCodes = new Set(lineup.entries.map((e) => e.country.code))
@@ -60,6 +64,164 @@ export class GameManager {
return this.predictions.has(playerId)
}
// ─── Jury Voting ────────────────────────────────────────────────
private currentJuryRound: {
id: string
countryCode: string
countryName: string
countryFlag: string
votes: Map<string, number>
} | null = null
private juryResults: JuryResult[] = []
private juryScores = new Map<string, number>()
openJuryRound(
countryCode: string,
countryName: string,
countryFlag: string,
): { success: true } | { error: string } {
if (this.currentJuryRound) return { error: "A jury round is already open" }
this.currentJuryRound = {
id: crypto.randomUUID(),
countryCode,
countryName,
countryFlag,
votes: new Map(),
}
return { success: true }
}
submitJuryVote(playerId: string, rating: number): { success: true } | { error: string } {
if (!this.currentJuryRound) return { error: "No jury round is open" }
if (rating < 1 || rating > 12) return { error: "Rating must be between 1 and 12" }
this.currentJuryRound.votes.set(playerId, rating)
return { success: true }
}
getPlayerJuryVote(playerId: string): number | null {
if (!this.currentJuryRound) return null
return this.currentJuryRound.votes.get(playerId) ?? null
}
closeJuryRound(): JuryResult | { error: string } {
if (!this.currentJuryRound) return { error: "No jury round is open" }
const round = this.currentJuryRound
const votes = Array.from(round.votes.values())
const averageRating = votes.length > 0
? Math.round((votes.reduce((a, b) => a + b, 0) / votes.length) * 10) / 10
: 0
const maxPts = scoringConfig.jury_max_per_round
for (const [playerId, rating] of round.votes) {
const diff = Math.abs(rating - averageRating)
const pts = Math.max(0, maxPts - Math.round(diff))
this.juryScores.set(playerId, (this.juryScores.get(playerId) ?? 0) + pts)
}
const result: JuryResult = {
countryCode: round.countryCode,
countryName: round.countryName,
countryFlag: round.countryFlag,
averageRating,
totalVotes: votes.length,
}
this.juryResults.push(result)
this.currentJuryRound = null
return result
}
getCurrentJuryRound(): JuryRound | null {
if (!this.currentJuryRound) return null
return {
id: this.currentJuryRound.id,
countryCode: this.currentJuryRound.countryCode,
countryName: this.currentJuryRound.countryName,
countryFlag: this.currentJuryRound.countryFlag,
status: "open",
}
}
getJuryResults(): JuryResult[] {
return this.juryResults
}
getJuryScore(playerId: string): number {
return this.juryScores.get(playerId) ?? 0
}
// ─── Bingo ──────────────────────────────────────────────────────
private bingoCards = new Map<string, {
squares: { tropeId: string; label: string; tapped: boolean }[]
hasBingo: boolean
}>()
private bingoAnnouncements: { playerId: string; displayName: string }[] = []
private announcedBingo = new Set<string>()
generateBingoCards(playerIds: string[]): void {
for (const playerId of playerIds) {
const shuffled = [...tropes].sort(() => Math.random() - 0.5)
const selected = shuffled.slice(0, 16)
this.bingoCards.set(playerId, {
squares: selected.map((t) => ({
tropeId: t.id,
label: t.label,
tapped: false,
})),
hasBingo: false,
})
}
}
getBingoCard(playerId: string): { squares: { tropeId: string; label: string; tapped: boolean }[]; hasBingo: boolean } | null {
return this.bingoCards.get(playerId) ?? null
}
tapBingoSquare(playerId: string, tropeId: string): { success: true; hasBingo: boolean } | { error: string } {
const card = this.bingoCards.get(playerId)
if (!card) return { error: "No bingo card found" }
const square = card.squares.find((s) => s.tropeId === tropeId)
if (!square) return { error: "Trope not on your card" }
square.tapped = !square.tapped
card.hasBingo = this.checkBingo(card.squares)
return { success: true, hasBingo: card.hasBingo }
}
private checkBingo(squares: { tapped: boolean }[]): boolean {
for (let row = 0; row < 4; row++) {
if (squares[row * 4]!.tapped && squares[row * 4 + 1]!.tapped && squares[row * 4 + 2]!.tapped && squares[row * 4 + 3]!.tapped) return true
}
for (let col = 0; col < 4; col++) {
if (squares[col]!.tapped && squares[col + 4]!.tapped && squares[col + 8]!.tapped && squares[col + 12]!.tapped) return true
}
if (squares[0]!.tapped && squares[5]!.tapped && squares[10]!.tapped && squares[15]!.tapped) return true
if (squares[3]!.tapped && squares[6]!.tapped && squares[9]!.tapped && squares[12]!.tapped) return true
return false
}
addBingoAnnouncement(playerId: string, displayName: string): boolean {
if (this.announcedBingo.has(playerId)) return false
this.announcedBingo.add(playerId)
this.bingoAnnouncements.push({ playerId, displayName })
return true
}
getBingoAnnouncements(): { playerId: string; displayName: string }[] {
return this.bingoAnnouncements
}
getBingoScore(playerId: string): number {
const card = this.bingoCards.get(playerId)
if (!card) return 0
const tappedCount = card.squares.filter((s) => s.tapped).length
let score = tappedCount * scoringConfig.bingo_per_square
if (card.hasBingo) score += scoringConfig.bingo_full_bonus
return score
}
// ─── State for client ───────────────────────────────────────────
private buildPredictionSubmitted(playerIds: string[]): Record<string, boolean> {
@@ -70,21 +232,59 @@ export class GameManager {
return result
}
getGameStateForPlayer(playerId: string, allPlayerIds: string[]): GameState {
getGameStateForPlayer(
playerId: string,
allPlayerIds: string[],
displayNames?: Record<string, string>,
): GameState {
return {
lineup,
myPrediction: this.getPrediction(playerId),
predictionsLocked: this.locked,
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
currentJuryRound: this.getCurrentJuryRound(),
juryResults: this.juryResults,
myJuryVote: this.getPlayerJuryVote(playerId),
myBingoCard: this.getBingoCard(playerId),
bingoAnnouncements: this.bingoAnnouncements,
leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}),
}
}
getGameStateForDisplay(allPlayerIds: string[]): GameState {
getGameStateForDisplay(
allPlayerIds: string[],
displayNames?: Record<string, string>,
): GameState {
return {
lineup,
myPrediction: null,
predictionsLocked: this.locked,
predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds),
currentJuryRound: this.getCurrentJuryRound(),
juryResults: this.juryResults,
myJuryVote: null,
myBingoCard: null,
bingoAnnouncements: this.bingoAnnouncements,
leaderboard: this.buildLeaderboard(allPlayerIds, displayNames ?? {}),
}
}
private buildLeaderboard(
playerIds: string[],
displayNames: Record<string, string>,
): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; totalPoints: number }[] {
return playerIds
.map((id) => {
const juryPoints = this.getJuryScore(id)
const bingoPoints = this.getBingoScore(id)
return {
playerId: id,
displayName: displayNames[id] ?? "Unknown",
juryPoints,
bingoPoints,
totalPoints: juryPoints + bingoPoints,
}
})
.sort((a, b) => b.totalPoints - a.totalPoints)
}
}

View File

@@ -154,6 +154,16 @@ export class RoomManager {
return Array.from(room.players.values()).map((p) => p.id)
}
getPlayerDisplayNames(code: string): Record<string, string> {
const room = this.rooms.get(code)
if (!room) return {}
const result: Record<string, string> = {}
for (const player of room.players.values()) {
result[player.id] = player.displayName
}
return result
}
getPlayerIdBySession(code: string, sessionId: string): string | null {
const room = this.rooms.get(code)
if (!room) return null

View File

@@ -47,7 +47,8 @@ function sendGameState(ws: WSContext, roomCode: string, sessionId: string) {
if (!gm || !playerId) return
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds)
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds, displayNames)
sendTo(ws, { type: "game_state", gameState })
}
@@ -56,7 +57,8 @@ function sendDisplayGameState(ws: WSContext, roomCode: string) {
if (!gm) return
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
const gameState = gm.getGameStateForDisplay(allPlayerIds)
const displayNames = roomManager.getPlayerDisplayNames(roomCode)
const gameState = gm.getGameStateForDisplay(allPlayerIds, displayNames)
sendTo(ws, { type: "game_state", gameState })
}
@@ -211,12 +213,15 @@ export function registerWebSocketRoutes() {
type: "act_changed",
newAct: result.newAct,
})
// Lock predictions when moving from pre-show to live-event
// Lock predictions and generate bingo cards when entering live-event
if (result.newAct === "live-event") {
const gm = roomManager.getGameManager(roomCode)
if (gm) {
gm.lockPredictions()
broadcast(roomCode, { type: "predictions_locked" })
const allPlayerIds = roomManager.getAllPlayerIds(roomCode)
gm.generateBingoCards(allPlayerIds)
broadcastGameStateToAll(roomCode)
}
}
break
@@ -258,6 +263,141 @@ export function registerWebSocketRoutes() {
broadcastGameStateToAll(roomCode)
break
}
case "open_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const room = roomManager.getRoom(roomCode)
if (room?.currentAct !== "live-event") {
sendError(ws, "Jury voting is only available during Live Event")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can open jury voting")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const entry = gm.getLineup().entries.find((e) => e.country.code === msg.countryCode)
if (!entry) {
sendError(ws, "Invalid country code")
return
}
const result = gm.openJuryRound(entry.country.code, entry.country.name, entry.country.flag)
if ("error" in result) {
sendError(ws, result.error)
return
}
const round = gm.getCurrentJuryRound()!
broadcast(roomCode, {
type: "jury_vote_opened",
roundId: round.id,
countryCode: round.countryCode,
countryName: round.countryName,
countryFlag: round.countryFlag,
})
broadcastGameStateToAll(roomCode)
break
}
case "close_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can close jury voting")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const result = gm.closeJuryRound()
if ("error" in result) {
sendError(ws, result.error)
return
}
broadcast(roomCode, {
type: "jury_vote_closed",
countryCode: result.countryCode,
countryName: result.countryName,
countryFlag: result.countryFlag,
averageRating: result.averageRating,
totalVotes: result.totalVotes,
})
broadcastGameStateToAll(roomCode)
break
}
case "submit_jury_vote": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
sendError(ws, "Jury voting is only available during Live Event")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.submitJuryVote(playerId, msg.rating)
if ("error" in result) {
sendError(ws, result.error)
return
}
sendGameState(ws, roomCode, sessionId)
break
}
case "tap_bingo_square": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
sendError(ws, "Bingo is only available during Live Event")
return
}
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
const gm = roomManager.getGameManager(roomCode)
if (!playerId || !gm) {
sendError(ws, "Room not found")
return
}
const result = gm.tapBingoSquare(playerId, msg.tropeId)
if ("error" in result) {
sendError(ws, result.error)
return
}
sendGameState(ws, roomCode, sessionId)
if (result.hasBingo) {
const room = roomManager.getRoom(roomCode)
const player = room?.players.find((p) => p.sessionId === sessionId)
if (player) {
const isNew = gm.addBingoAnnouncement(playerId, player.displayName)
if (isNew) {
broadcast(roomCode, {
type: "bingo_announced",
playerId,
displayName: player.displayName,
})
broadcastGameStateToAll(roomCode)
}
}
}
break
}
}
},

View File

@@ -72,6 +72,122 @@ describe("GameManager", () => {
})
})
describe("bingo", () => {
it("generates a bingo card with 16 unique squares", () => {
gm.generateBingoCards(["p1", "p2"])
const card = gm.getBingoCard("p1")
expect(card).not.toBeNull()
expect(card!.squares).toHaveLength(16)
expect(card!.hasBingo).toBe(false)
const ids = card!.squares.map((s) => s.tropeId)
expect(new Set(ids).size).toBe(16)
})
it("generates different cards for different players", () => {
gm.generateBingoCards(["p1", "p2"])
const card1 = gm.getBingoCard("p1")!
const card2 = gm.getBingoCard("p2")!
const ids1 = card1.squares.map((s) => s.tropeId).sort()
const ids2 = card2.squares.map((s) => s.tropeId).sort()
expect(ids1).not.toEqual(ids2)
})
it("taps a bingo square", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
const result = gm.tapBingoSquare("p1", tropeId)
expect(result).toMatchObject({ success: true, hasBingo: false })
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true)
})
it("rejects tap on unknown trope", () => {
gm.generateBingoCards(["p1"])
const result = gm.tapBingoSquare("p1", "nonexistent")
expect(result).toEqual({ error: "Trope not on your card" })
})
it("rejects tap when no card exists", () => {
const result = gm.tapBingoSquare("p1", "key-change")
expect(result).toEqual({ error: "No bingo card found" })
})
it("allows untapping a square", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
gm.tapBingoSquare("p1", tropeId)
gm.tapBingoSquare("p1", tropeId)
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(false)
})
it("detects bingo on a completed row", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (let i = 0; i < 4; i++) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
})
it("detects bingo on a completed column", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (const i of [0, 4, 8, 12]) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
})
it("detects bingo on a diagonal", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (const i of [0, 5, 10, 15]) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
})
it("revokes bingo when a completing square is untapped", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
expect(gm.getBingoCard("p1")!.hasBingo).toBe(false)
})
it("does not duplicate bingo announcements on re-bingo", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(true)
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // untap
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // re-tap → re-bingo
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(false)
expect(gm.getBingoAnnouncements()).toHaveLength(1)
})
it("computes bingo score", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
gm.tapBingoSquare("p1", card.squares[4]!.tropeId)
// 5 tapped * 2 + 10 bingo bonus = 20
expect(gm.getBingoScore("p1")).toBe(20)
})
})
describe("getGameStateForPlayer", () => {
it("includes only the requesting player's prediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
@@ -87,6 +203,142 @@ describe("GameManager", () => {
})
})
describe("jury voting", () => {
it("opens a jury round", () => {
const result = gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(result).toEqual({ success: true })
const round = gm.getCurrentJuryRound()
expect(round).not.toBeNull()
expect(round!.countryCode).toBe("SE")
expect(round!.status).toBe("open")
})
it("rejects opening when a round is already open", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.openJuryRound("DE", "Germany", "🇩🇪")
expect(result).toEqual({ error: "A jury round is already open" })
})
it("accepts a valid jury vote", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ success: true })
})
it("rejects jury vote when no round is open", () => {
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ error: "No jury round is open" })
})
it("rejects jury vote outside 1-12 range", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 0)
expect(result).toEqual({ error: "Rating must be between 1 and 12" })
})
it("allows overwriting a jury vote in the same round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p1", 10)
const result = gm.closeJuryRound()
expect("averageRating" in result && result.averageRating).toBe(10)
})
it("closes a jury round and computes average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p2", 10)
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 9,
totalVotes: 2,
})
})
it("rejects close when no round is open", () => {
const result = gm.closeJuryRound()
expect(result).toEqual({ error: "No jury round is open" })
})
it("handles closing a round with zero votes", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 0,
totalVotes: 0,
})
})
it("accumulates results across rounds", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10)
gm.closeJuryRound()
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 6)
gm.closeJuryRound()
expect(gm.getJuryResults()).toHaveLength(2)
})
it("computes jury scores based on closeness to average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10) // avg will be 10, diff=0, score=5
gm.submitJuryVote("p2", 10) // diff=0, score=5
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(5)
expect(gm.getJuryScore("p2")).toBe(5)
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 4) // avg=(4+10)/2=7, diff=3, score=2
gm.submitJuryVote("p2", 10) // diff=3, score=2
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(7) // 5+2
expect(gm.getJuryScore("p2")).toBe(7) // 5+2
})
it("returns the player's current vote for a round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(gm.getPlayerJuryVote("p1")).toBeNull()
gm.submitJuryVote("p1", 7)
expect(gm.getPlayerJuryVote("p1")).toBe(7)
})
})
describe("getGameStateForPlayer (with jury + bingo)", () => {
it("includes jury round state", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
const state = gm.getGameStateForPlayer("p1", ["p1"])
expect(state.currentJuryRound).not.toBeNull()
expect(state.currentJuryRound!.countryCode).toBe("SE")
expect(state.myJuryVote).toBe(8)
})
it("includes bingo card", () => {
gm.generateBingoCards(["p1"])
const state = gm.getGameStateForPlayer("p1", ["p1"])
expect(state.myBingoCard).not.toBeNull()
expect(state.myBingoCard!.squares).toHaveLength(16)
})
it("includes leaderboard with jury + bingo scores", () => {
gm.generateBingoCards(["p1"])
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.closeJuryRound()
const card = gm.getBingoCard("p1")!
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" })
expect(state.leaderboard).toHaveLength(1)
expect(state.leaderboard[0]!.juryPoints).toBe(5) // solo voter = exact match
expect(state.leaderboard[0]!.bingoPoints).toBe(2) // 1 tapped * 2
expect(state.leaderboard[0]!.totalPoints).toBe(7)
})
})
describe("getGameStateForDisplay", () => {
it("returns null myPrediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")

View File

@@ -37,6 +37,57 @@ export const predictionSchema = z.object({
export type Prediction = z.infer<typeof predictionSchema>
// ─── Jury Voting ────────────────────────────────────────────────────
export const juryRoundSchema = z.object({
id: z.string(),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
status: z.enum(["open", "closed"]),
})
export type JuryRound = z.infer<typeof juryRoundSchema>
export const juryResultSchema = z.object({
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
averageRating: z.number(),
totalVotes: z.number(),
})
export type JuryResult = z.infer<typeof juryResultSchema>
// ─── Bingo ──────────────────────────────────────────────────────────
export const bingoSquareSchema = z.object({
tropeId: z.string(),
label: z.string(),
tapped: z.boolean(),
})
export type BingoSquare = z.infer<typeof bingoSquareSchema>
export const bingoCardSchema = z.object({
squares: z.array(bingoSquareSchema).length(16),
hasBingo: z.boolean(),
})
export type BingoCard = z.infer<typeof bingoCardSchema>
// ─── Scoring ────────────────────────────────────────────────────────
export const leaderboardEntrySchema = z.object({
playerId: z.string(),
displayName: z.string(),
juryPoints: z.number(),
bingoPoints: z.number(),
totalPoints: z.number(),
})
export type LeaderboardEntry = z.infer<typeof leaderboardEntrySchema>
// ─── Game State (sent to clients) ───────────────────────────────────
export const gameStateSchema = z.object({
@@ -44,6 +95,18 @@ export const gameStateSchema = z.object({
myPrediction: predictionSchema.nullable(),
predictionsLocked: z.boolean(),
predictionSubmitted: z.record(z.string(), z.boolean()),
// Jury
currentJuryRound: juryRoundSchema.nullable(),
juryResults: z.array(juryResultSchema),
myJuryVote: z.number().nullable(),
// Bingo
myBingoCard: bingoCardSchema.nullable(),
bingoAnnouncements: z.array(z.object({
playerId: z.string(),
displayName: z.string(),
})),
// Leaderboard
leaderboard: z.array(leaderboardEntrySchema),
})
export type GameState = z.infer<typeof gameStateSchema>

View File

@@ -31,12 +31,35 @@ export const submitPredictionMessage = z.object({
last: z.string(),
})
export const openJuryVoteMessage = z.object({
type: z.literal("open_jury_vote"),
countryCode: z.string(),
})
export const closeJuryVoteMessage = z.object({
type: z.literal("close_jury_vote"),
})
export const submitJuryVoteMessage = z.object({
type: z.literal("submit_jury_vote"),
rating: z.number().int().min(1).max(12),
})
export const tapBingoSquareMessage = z.object({
type: z.literal("tap_bingo_square"),
tropeId: z.string(),
})
export const clientMessage = z.discriminatedUnion("type", [
joinRoomMessage,
reconnectMessage,
advanceActMessage,
endRoomMessage,
submitPredictionMessage,
openJuryVoteMessage,
closeJuryVoteMessage,
submitJuryVoteMessage,
tapBingoSquareMessage,
])
export type ClientMessage = z.infer<typeof clientMessage>
@@ -87,6 +110,29 @@ export const predictionsLockedMessage = z.object({
type: z.literal("predictions_locked"),
})
export const juryVoteOpenedMessage = z.object({
type: z.literal("jury_vote_opened"),
roundId: z.string(),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
})
export const juryVoteClosedMessage = z.object({
type: z.literal("jury_vote_closed"),
countryCode: z.string(),
countryName: z.string(),
countryFlag: z.string(),
averageRating: z.number(),
totalVotes: z.number(),
})
export const bingoAnnouncedMessage = z.object({
type: z.literal("bingo_announced"),
playerId: z.string(),
displayName: z.string(),
})
export const serverMessage = z.discriminatedUnion("type", [
roomStateMessage,
playerJoinedMessage,
@@ -97,6 +143,9 @@ export const serverMessage = z.discriminatedUnion("type", [
errorMessage,
gameStateMessage,
predictionsLockedMessage,
juryVoteOpenedMessage,
juryVoteClosedMessage,
bingoAnnouncedMessage,
])
export type ServerMessage = z.infer<typeof serverMessage>