Mixed mode (default):
- gameType is now mixed | quiz | blindtest, default "mixed"; the lobby
interleaves quiz + blindtest rounds so modes alternate
- lobby reworked: 3-way game-type switch, quiz count and/or blindtest config
+ track submission shown per selected type
Free-text quiz questions:
- quiz supports the `free` format (e.g. maths) alongside mcq/truefalse:
payload without choices, {text} vote, tolerant matching on acceptedAnswers
- shared QuizQuestionPayload.choices optional; reveal truth carries answer
- match util moved to game/match.ts (shared by quiz + blindtest)
- repo/seed include free + acceptedAnswers; in-code bank gains free questions
- client quiz view renders a text input for free questions
Mode-aware transitions:
- RoundTransition themed per mode (colors/label/emoji); announces the mode
on change (quiz <-> blindtest), lighter teaser within the same mode
- custom media buckets per mode: assets/transitions/{quiz,blindtest}/ + shared
- store tracks roundModeChanged
Verified e2e: mixed game alternates quiz/blindtest to completion; free-text
scoring covered by tests (22 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
272 lines
8.3 KiB
TypeScript
272 lines
8.3 KiB
TypeScript
import { useState } from "react"
|
|
import type {
|
|
BlindtestMode,
|
|
GameType,
|
|
RoomSnapshot,
|
|
RoundConfig,
|
|
} from "@nerdware/shared"
|
|
import { Button } from "@workspace/ui/components/button"
|
|
import { useRoomStore } from "@/store/room"
|
|
import { Avatar } from "@/components/avatar"
|
|
|
|
const QUESTION_OPTIONS = [3, 5, 10]
|
|
const TRACKS_OPTIONS = [1, 2, 3]
|
|
const GAME_TYPES: { value: GameType; label: string }[] = [
|
|
{ value: "mixed", label: "Mixte" },
|
|
{ value: "quiz", label: "Quiz" },
|
|
{ value: "blindtest", label: "Blindtest" },
|
|
]
|
|
const MODE_LABELS: Record<BlindtestMode, string> = {
|
|
title_artist: "Titre & artiste",
|
|
who_added: "Qui l'a ajouté ?",
|
|
mixed: "Mixte",
|
|
}
|
|
|
|
const inputClass =
|
|
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
|
|
|
/** Construit la séquence de manches selon le type de partie. */
|
|
function buildRounds(
|
|
gameType: GameType,
|
|
quizCount: number,
|
|
totalTracks: number
|
|
): RoundConfig[] {
|
|
if (gameType === "quiz") {
|
|
return Array.from({ length: quizCount }, () => ({ type: "quiz" }))
|
|
}
|
|
if (gameType === "blindtest") {
|
|
return Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
|
|
}
|
|
// Mixte : on alterne quiz / blindtest pour multiplier les changements de mode.
|
|
const rounds: RoundConfig[] = []
|
|
let q = quizCount
|
|
let b = totalTracks
|
|
while (q > 0 || b > 0) {
|
|
if (q > 0) {
|
|
rounds.push({ type: "quiz" })
|
|
q--
|
|
}
|
|
if (b > 0) {
|
|
rounds.push({ type: "blindtest" })
|
|
b--
|
|
}
|
|
}
|
|
return rounds
|
|
}
|
|
|
|
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|
const playerId = useRoomStore((s) => s.playerId)
|
|
const updateSettings = useRoomStore((s) => s.updateSettings)
|
|
const startGame = useRoomStore((s) => s.startGame)
|
|
const isHost = snapshot.hostId === playerId
|
|
const { gameType, blindtestMode, tracksPerPlayer } = snapshot.settings
|
|
|
|
const [count, setCount] = useState(5)
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0)
|
|
const myCount =
|
|
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
|
|
const showQuiz = gameType === "quiz" || gameType === "mixed"
|
|
const showBlindtest = gameType === "blindtest" || gameType === "mixed"
|
|
|
|
const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks)
|
|
const canStart = rounds.length > 0
|
|
|
|
async function start() {
|
|
setError(null)
|
|
setBusy(true)
|
|
try {
|
|
await startGame(rounds)
|
|
} catch (err) {
|
|
setError((err as { message?: string }).message ?? "Erreur")
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex w-full max-w-md flex-col gap-6">
|
|
<section className="flex flex-col gap-2">
|
|
<h2 className="text-muted-foreground text-sm font-medium">
|
|
Joueurs ({snapshot.players.length})
|
|
</h2>
|
|
<ul className="flex flex-col gap-1">
|
|
{snapshot.players.map((p) => (
|
|
<li
|
|
key={p.id}
|
|
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
|
>
|
|
<span
|
|
className={`flex items-center gap-2 ${p.connected ? "" : "opacity-40"}`}
|
|
>
|
|
<Avatar seed={p.name} className="size-11" />
|
|
{p.name}
|
|
{p.id === playerId && (
|
|
<span className="text-muted-foreground"> (toi)</span>
|
|
)}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
{p.id === snapshot.hostId ? "Hôte" : ""}
|
|
{!p.connected && " · hors ligne"}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
|
|
{isHost && (
|
|
<div className="flex gap-2">
|
|
{GAME_TYPES.map((t) => (
|
|
<Button
|
|
key={t.value}
|
|
className="flex-1"
|
|
variant={gameType === t.value ? "default" : "secondary"}
|
|
onClick={() => updateSettings({ gameType: t.value })}
|
|
>
|
|
{t.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isHost && showQuiz && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Questions de quiz</span>
|
|
<div className="flex gap-1">
|
|
{QUESTION_OPTIONS.map((n) => (
|
|
<Button
|
|
key={n}
|
|
size="sm"
|
|
variant={count === n ? "default" : "secondary"}
|
|
onClick={() => setCount(n)}
|
|
>
|
|
{n}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isHost && showBlindtest && (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-sm font-medium">Mode blindtest</span>
|
|
<div className="flex gap-1">
|
|
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
|
|
<Button
|
|
key={m}
|
|
size="sm"
|
|
className="flex-1"
|
|
variant={blindtestMode === m ? "default" : "secondary"}
|
|
onClick={() => updateSettings({ blindtestMode: m })}
|
|
>
|
|
{MODE_LABELS[m]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Titres par joueur</span>
|
|
<div className="flex gap-1">
|
|
{TRACKS_OPTIONS.map((n) => (
|
|
<Button
|
|
key={n}
|
|
size="sm"
|
|
variant={tracksPerPlayer === n ? "default" : "secondary"}
|
|
onClick={() => updateSettings({ tracksPerPlayer: n })}
|
|
>
|
|
{n}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showBlindtest && (
|
|
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
|
|
)}
|
|
|
|
{isHost ? (
|
|
<Button disabled={busy || !canStart} onClick={start}>
|
|
{busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
|
</Button>
|
|
) : (
|
|
<p className="text-muted-foreground text-center text-xs">
|
|
En attente du lancement par l'hôte…
|
|
{showBlindtest && ` (${totalTracks} titres soumis)`}
|
|
</p>
|
|
)}
|
|
|
|
{error && <p className="text-destructive text-center text-sm">{error}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TrackSubmission({
|
|
tracksPerPlayer,
|
|
myCount,
|
|
}: {
|
|
tracksPerPlayer: number
|
|
myCount: number
|
|
}) {
|
|
const submitTrack = useRoomStore((s) => s.submitTrack)
|
|
const [url, setUrl] = useState("")
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [feedback, setFeedback] = useState<string | null>(null)
|
|
const [accepted, setAccepted] = useState<string[]>([])
|
|
const quotaReached = myCount >= tracksPerPlayer
|
|
|
|
async function submit() {
|
|
if (!url.trim()) {
|
|
return
|
|
}
|
|
setSubmitting(true)
|
|
setFeedback(null)
|
|
const res = await submitTrack(url.trim())
|
|
if (res.accepted) {
|
|
setAccepted((a) => [...a, res.title ?? url.trim()])
|
|
setUrl("")
|
|
} else {
|
|
setFeedback(res.reason ?? "Refusé")
|
|
}
|
|
setSubmitting(false)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-sm font-medium">
|
|
Tes titres ({myCount}/{tracksPerPlayer})
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<input
|
|
className={inputClass}
|
|
placeholder="Lien YouTube"
|
|
value={url}
|
|
disabled={quotaReached || submitting}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
|
/>
|
|
<Button
|
|
variant="secondary"
|
|
disabled={quotaReached || submitting || !url.trim()}
|
|
onClick={submit}
|
|
>
|
|
Ajouter
|
|
</Button>
|
|
</div>
|
|
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
|
|
{accepted.length > 0 && (
|
|
<ul className="text-muted-foreground flex flex-col gap-0.5 text-xs">
|
|
{accepted.map((t, i) => (
|
|
<li key={i} className="truncate">
|
|
✓ {t}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|