Compare commits
20 Commits
6f1a63e4c9
...
f22dba6134
| Author | SHA1 | Date | |
|---|---|---|---|
| f22dba6134 | |||
| 60a5962519 | |||
| a71308f6f0 | |||
| 611a1bf732 | |||
| c768d7340a | |||
| f6223ae9fa | |||
| 7f5dba6e03 | |||
| 8ee9295b4e | |||
| 094fd1feeb | |||
| d247c2519e | |||
| 302f2e14c0 | |||
| ceba5521dc | |||
| 7cb52291f3 | |||
| aedd3c032a | |||
| b79ddb9679 | |||
| 0703364945 | |||
| e48ee2ca35 | |||
| 9ec0225e4b | |||
| c31f849de3 | |||
| 0019024066 |
2116
docs/superpowers/plans/2026-03-12-act2-jury-bingo.md
Normal file
2116
docs/superpowers/plans/2026-03-12-act2-jury-bingo.md
Normal file
File diff suppressed because it is too large
Load Diff
43
packages/client/src/components/bingo-card.tsx
Normal file
43
packages/client/src/components/bingo-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
packages/client/src/components/bingo-display.tsx
Normal file
18
packages/client/src/components/bingo-display.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
packages/client/src/components/jury-display.tsx
Normal file
73
packages/client/src/components/jury-display.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
packages/client/src/components/jury-host.tsx
Normal file
91
packages/client/src/components/jury-host.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
packages/client/src/components/jury-voting.tsx
Normal file
57
packages/client/src/components/jury-voting.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
packages/client/src/components/leaderboard.tsx
Normal file
37
packages/client/src/components/leaderboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
37
packages/server/data/bingo-tropes.json
Normal file
37
packages/server/data/bingo-tropes.json
Normal 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" }
|
||||
]
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user