feature/blindtest #6

Merged
ayoub merged 9 commits from feature/blindtest into dev 2026-06-10 14:25:59 +00:00
17 changed files with 529 additions and 283 deletions
Showing only changes of commit e3315d3b35 - Show all commits

View file

@ -1,6 +1,6 @@
// Accès aux questions de quiz en base. No-op si pas de DB (db === null). // Accès aux questions de quiz en base. No-op si pas de DB (db === null).
import { and, eq, inArray, isNotNull, notInArray, sql } from "drizzle-orm" import { and, eq, inArray, notInArray, sql } from "drizzle-orm"
import { db } from "./index" import { db } from "./index"
import { quizCategory, quizPlayed, quizQuestion } from "./schema" import { quizCategory, quizPlayed, quizQuestion } from "./schema"
import type { QuizQuestion } from "../game/modes/quiz/questions" import type { QuizQuestion } from "../game/modes/quiz/questions"
@ -28,6 +28,7 @@ export async function loadQuizPool(
prompt: quizQuestion.prompt, prompt: quizQuestion.prompt,
choices: quizQuestion.choices, choices: quizQuestion.choices,
correctIndex: quizQuestion.correctIndex, correctIndex: quizQuestion.correctIndex,
acceptedAnswers: quizQuestion.acceptedAnswers,
difficulty: quizQuestion.difficulty, difficulty: quizQuestion.difficulty,
category: quizCategory.name, category: quizCategory.name,
}) })
@ -35,8 +36,7 @@ export async function loadQuizPool(
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where( .where(
and( and(
inArray(quizQuestion.format, ["mcq", "truefalse"]), inArray(quizQuestion.format, ["mcq", "truefalse", "free"]),
isNotNull(quizQuestion.correctIndex),
notInArray(quizQuestion.id, alreadyPlayed) notInArray(quizQuestion.id, alreadyPlayed)
) )
) )
@ -44,13 +44,18 @@ export async function loadQuizPool(
.limit(limit) .limit(limit)
return rows return rows
.filter((r) => r.choices && r.correctIndex !== null) .filter((r) =>
r.format === "free"
? (r.acceptedAnswers?.length ?? 0) > 0
: r.choices && r.correctIndex !== null
)
.map((r) => ({ .map((r) => ({
id: r.id, id: r.id,
format: r.format as QuizQuestion["format"], format: r.format as QuizQuestion["format"],
prompt: r.prompt, prompt: r.prompt,
choices: r.choices as string[], choices: r.choices ?? undefined,
correctIndex: r.correctIndex as number, correctIndex: r.correctIndex ?? undefined,
acceptedAnswers: r.acceptedAnswers ?? undefined,
category: r.category ?? "Quiz", category: r.category ?? "Quiz",
difficulty: r.difficulty, difficulty: r.difficulty,
})) }))

View file

@ -179,6 +179,7 @@ async function seedManual(): Promise<number> {
source: "manual", source: "manual",
choices: q.choices, choices: q.choices,
correctIndex: q.correctIndex, correctIndex: q.correctIndex,
acceptedAnswers: q.acceptedAnswers,
}, },
]) ])
} }

View file

@ -3,7 +3,7 @@ import { RoomManager, type BlindtestTrack } from "../../../rooms"
import type { RoundContext } from "../../round" import type { RoundContext } from "../../round"
import { BlindtestRound } from "./blindtest-round" import { BlindtestRound } from "./blindtest-round"
import { prepareBlindtestForRoom } from "./pool" import { prepareBlindtestForRoom } from "./pool"
import { fuzzyMatch, normalize } from "./match" import { fuzzyMatch, normalize } from "../../match"
function track(submittedBy: string): BlindtestTrack { function track(submittedBy: string): BlindtestTrack {
return { return {

View file

@ -12,7 +12,7 @@ import type {
} from "@nerdware/shared" } from "@nerdware/shared"
import type { GameRound, RoundContext, RoundStart } from "../../round" import type { GameRound, RoundContext, RoundStart } from "../../round"
import type { BlindtestTrack, ServerRoom } from "../../../rooms" import type { BlindtestTrack, ServerRoom } from "../../../rooms"
import { fuzzyMatch } from "./match" import { fuzzyMatch } from "../../match"
import { takeTrack } from "./pool" import { takeTrack } from "./pool"
const TITLE_POINTS = 60 const TITLE_POINTS = 60

View file

@ -8,8 +8,12 @@ export interface QuizQuestion {
id: string id: string
format: QuizFormat format: QuizFormat
prompt: string prompt: string
choices: string[] /** mcq/truefalse uniquement. */
correctIndex: number choices?: string[]
/** mcq/truefalse uniquement. */
correctIndex?: number
/** free : réponses acceptées (matching tolérant). */
acceptedAnswers?: string[]
category: string category: string
difficulty: number difficulty: number
} }
@ -107,4 +111,45 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [
category: "Pop culture", category: "Pop culture",
difficulty: 1, difficulty: 1,
}, },
// Saisie libre (matching tolérant sur acceptedAnswers).
{
id: "q-free-math-1",
format: "free",
prompt: "Combien font 7 × 8 ?",
acceptedAnswers: ["56"],
category: "Maths",
difficulty: 1,
},
{
id: "q-free-math-2",
format: "free",
prompt: "Résous : 12² 44 = ?",
acceptedAnswers: ["100", "cent"],
category: "Maths",
difficulty: 2,
},
{
id: "q-free-zelda-princess",
format: "free",
prompt: "Comment s'appelle la princesse dans The Legend of Zelda ?",
acceptedAnswers: ["Zelda"],
category: "Jeux vidéo",
difficulty: 1,
},
{
id: "q-free-pi",
format: "free",
prompt: "Donne les 3 premières décimales de Pi (après la virgule).",
acceptedAnswers: ["141"],
category: "Maths",
difficulty: 2,
},
{
id: "q-free-onepiece",
format: "free",
prompt: "Quel est le nom du bateau de l'équipage de Luffy (2e navire) ?",
acceptedAnswers: ["Thousand Sunny", "Sunny", "le Thousand Sunny"],
category: "Manga",
difficulty: 3,
},
] ]

View file

@ -2,11 +2,15 @@ import { describe, expect, test } from "bun:test"
import { RoomManager } from "../../../rooms" import { RoomManager } from "../../../rooms"
import type { RoundContext } from "../../round" import type { RoundContext } from "../../round"
import { QuizRound } from "./quiz-round" import { QuizRound } from "./quiz-round"
import { QUIZ_QUESTIONS } from "./questions" import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1 const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1
function makeCtx(): { ctx: RoundContext; p1: string; p2: string } { function makeCtx(q: QuizQuestion = question): {
ctx: RoundContext
p1: string
p2: string
} {
const rooms = new RoomManager() const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa") const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb") const { player: b } = rooms.join(room.code, "Bob", "sb")
@ -16,7 +20,7 @@ function makeCtx(): { ctx: RoundContext; p1: string; p2: string } {
votes: new Map(), votes: new Map(),
startedAt: 0, startedAt: 0,
endsAt: 10_000, endsAt: 10_000,
data: { question, votedAt: new Map() }, data: { question: q, votedAt: new Map() },
} }
return { ctx, p1: a.id, p2: b.id } return { ctx, p1: a.id, p2: b.id }
} }
@ -56,4 +60,24 @@ describe("QuizRound", () => {
const deltas = round.score(ctx) const deltas = round.score(ctx)
expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9) expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9)
}) })
test("free : matching tolérant sur acceptedAnswers", () => {
const free: QuizQuestion = {
id: "f1",
format: "free",
prompt: "7 × 8 ?",
acceptedAnswers: ["56"],
category: "Maths",
difficulty: 1,
}
const round = new QuizRound(() => 1000)
const { ctx, p1, p2 } = makeCtx(free)
round.submitAnswer(ctx, p1, { text: " 56 " }) // juste (espaces tolérés)
round.submitAnswer(ctx, p2, { text: "57" }) // faux
const { truth, perPlayerResult } = round.reveal(ctx)
expect(truth).toEqual({ answer: "56" })
expect(perPlayerResult[p1].correct).toBe(true)
expect(perPlayerResult[p2].correct).toBe(false)
expect(round.score(ctx)).toEqual([{ playerId: p1, delta: 190 }])
})
}) })

View file

@ -1,5 +1,5 @@
// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio. // Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio.
// Implémente le contrat GameRound ; le moteur fait tout le reste. // Formats : mcq, truefalse (choix), free (saisie libre, matching tolérant).
import type { import type {
Answer, Answer,
@ -10,6 +10,7 @@ import type {
} from "@nerdware/shared" } from "@nerdware/shared"
import { hasDb } from "../../../db" import { hasDb } from "../../../db"
import { markQuestionPlayed } from "../../../db/quiz-repo" import { markQuestionPlayed } from "../../../db/quiz-repo"
import { fuzzyMatch } from "../../match"
import type { GameRound, RoundContext, RoundStart } from "../../round" import type { GameRound, RoundContext, RoundStart } from "../../round"
import type { ServerRoom } from "../../../rooms" import type { ServerRoom } from "../../../rooms"
import type { QuizQuestion } from "./questions" import type { QuizQuestion } from "./questions"
@ -25,10 +26,29 @@ interface QuizRoundData {
votedAt: Map<string, number> votedAt: Map<string, number>
} }
function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } { function asChoice(answer: Answer): number | null {
return ( const v = (answer as { choiceIndex?: unknown }).choiceIndex
typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number" return typeof v === "number" ? v : null
) }
function asText(answer: Answer): string | null {
const v = (answer as { text?: unknown }).text
return typeof v === "string" ? v : null
}
/** Réponse correcte ? Selon le format de la question. */
function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean {
if (!answer) {
return false
}
if (question.format === "free") {
const text = asText(answer)
if (!text) {
return false
}
return (question.acceptedAnswers ?? []).some((a) => fuzzyMatch(text, a))
}
return asChoice(answer) === question.correctIndex
} }
export class QuizRound implements GameRound { export class QuizRound implements GameRound {
@ -45,10 +65,12 @@ export class QuizRound implements GameRound {
const payload: QuizQuestionPayload = { const payload: QuizQuestionPayload = {
format: question.format, format: question.format,
prompt: question.prompt, prompt: question.prompt,
choices: question.choices,
category: question.category, category: question.category,
difficulty: question.difficulty, difficulty: question.difficulty,
} }
if (question.format !== "free") {
payload.choices = question.choices
}
const data: QuizRoundData = { question, votedAt: new Map() } const data: QuizRoundData = { question, votedAt: new Map() }
return { djId: null, payload, data } return { djId: null, payload, data }
} }
@ -58,29 +80,42 @@ export class QuizRound implements GameRound {
if (ctx.votes.has(playerId)) { if (ctx.votes.has(playerId)) {
return return
} }
if (!isQuizAnswer(answer)) {
return
}
const { question, votedAt } = ctx.data as QuizRoundData const { question, votedAt } = ctx.data as QuizRoundData
if (answer.choiceIndex < 0 || answer.choiceIndex >= question.choices.length) { if (question.format === "free") {
const text = asText(answer)
if (text === null || text.trim().length === 0) {
return return
} }
ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex }) ctx.votes.set(playerId, { text })
} else {
const choiceIndex = asChoice(answer)
const count = question.choices?.length ?? 0
if (choiceIndex === null || choiceIndex < 0 || choiceIndex >= count) {
return
}
ctx.votes.set(playerId, { choiceIndex })
}
votedAt.set(playerId, this.now()) votedAt.set(playerId, this.now())
} }
reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } { reveal(ctx: RoundContext): {
truth: QuizRevealTruth
perPlayerResult: QuizPerPlayerResult
} {
const { question } = ctx.data as QuizRoundData const { question } = ctx.data as QuizRoundData
const perPlayerResult: QuizPerPlayerResult = {} const perPlayerResult: QuizPerPlayerResult = {}
for (const player of ctx.room.players.values()) { for (const player of ctx.room.players.values()) {
const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined const vote = ctx.votes.get(player.id)
const choiceIndex = vote ? vote.choiceIndex : null
perPlayerResult[player.id] = { perPlayerResult[player.id] = {
choiceIndex, choiceIndex: vote ? asChoice(vote) : null,
correct: choiceIndex === question.correctIndex, correct: isCorrect(question, vote),
} }
} }
return { truth: { correctIndex: question.correctIndex }, perPlayerResult } const truth: QuizRevealTruth =
question.format === "free"
? { answer: question.acceptedAnswers?.[0] ?? "" }
: { correctIndex: question.correctIndex }
return { truth, perPlayerResult }
} }
score(ctx: RoundContext): ScoreDelta[] { score(ctx: RoundContext): ScoreDelta[] {
@ -88,7 +123,7 @@ export class QuizRound implements GameRound {
const total = ctx.endsAt - ctx.startedAt const total = ctx.endsAt - ctx.startedAt
const deltas: ScoreDelta[] = [] const deltas: ScoreDelta[] = []
for (const [playerId, vote] of ctx.votes) { for (const [playerId, vote] of ctx.votes) {
if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) { if (!isCorrect(question, vote)) {
continue continue
} }
// Bonus rapidité : proportionnel au temps restant au moment du vote. // Bonus rapidité : proportionnel au temps restant au moment du vote.

View file

@ -3,9 +3,19 @@
Dépose ici des fichiers d'animation pour les transitions entre manches : Dépose ici des fichiers d'animation pour les transitions entre manches :
formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`. formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
- Ils sont détectés automatiquement au build (`import.meta.glob`). Rangement par mode (recommandé pour des transitions personnalisées) :
- À chaque manche, un fichier est tiré **au hasard** parmi ceux présents.
- S'il n'y en a aucun, l'animation Framer par défaut (numéro + « PRÊT ?! ») est jouée. - `quiz/` → jouées avant une manche de **quiz**
- `blindtest/` → jouées avant une manche de **blindtest**
- racine (`./`) → **communs**, fallback pour les deux modes
Règles :
- Détectés automatiquement au build (`import.meta.glob`).
- À chaque manche, un fichier est tiré **au hasard** dans le pool du mode
(dossier du mode + communs).
- S'il n'y en a aucun, l'animation Framer par défaut est jouée : annonce du
mode (« QUIZ » / « BLINDTEST ») quand on change de mode, sinon « Question/Titre N ».
Le média est centré, limité à 70vh / 80vw, et joué par-dessus le wipe coloré Le média est centré, limité à 70vh / 80vw, et joué par-dessus le wipe coloré
plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans

View file

@ -1,11 +1,21 @@
import { useState } from "react" import { useState } from "react"
import type { BlindtestMode, RoomSnapshot, RoundConfig } from "@nerdware/shared" import type {
BlindtestMode,
GameType,
RoomSnapshot,
RoundConfig,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
const QUESTION_OPTIONS = [3, 5, 10] const QUESTION_OPTIONS = [3, 5, 10]
const TRACKS_OPTIONS = [1, 2, 3] 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> = { const MODE_LABELS: Record<BlindtestMode, string> = {
title_artist: "Titre & artiste", title_artist: "Titre & artiste",
who_added: "Qui l'a ajouté ?", who_added: "Qui l'a ajouté ?",
@ -15,6 +25,35 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
const inputClass = 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" "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 }) { export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const updateSettings = useRoomStore((s) => s.updateSettings) const updateSettings = useRoomStore((s) => s.updateSettings)
@ -27,8 +66,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) 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"
async function start(rounds: RoundConfig[]) { const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks)
const canStart = rounds.length > 0
async function start() {
setError(null) setError(null)
setBusy(true) setBusy(true)
try { try {
@ -70,25 +116,22 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</ul> </ul>
</section> </section>
{/* Choix du type de partie (hôte). */}
{isHost && ( {isHost && (
<div className="flex gap-2"> <div className="flex gap-2">
{(["quiz", "blindtest"] as const).map((type) => ( {GAME_TYPES.map((t) => (
<Button <Button
key={type} key={t.value}
className="flex-1" className="flex-1"
variant={gameType === type ? "default" : "secondary"} variant={gameType === t.value ? "default" : "secondary"}
onClick={() => updateSettings({ gameType: type })} onClick={() => updateSettings({ gameType: t.value })}
> >
{type === "quiz" ? "Quiz" : "Blindtest"} {t.label}
</Button> </Button>
))} ))}
</div> </div>
)} )}
{gameType === "quiz" ? ( {isHost && showQuiz && (
isHost ? (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Questions de quiz</span> <span className="text-sm font-medium">Questions de quiz</span>
<div className="flex gap-1"> <div className="flex gap-1">
@ -104,107 +147,20 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
))} ))}
</div> </div>
</div> </div>
<Button
disabled={busy}
onClick={() =>
start(Array.from({ length: count }, () => ({ type: "quiz" })))
}
>
{busy ? "Lancement…" : "Lancer la partie"}
</Button>
</section>
) : (
<p className="text-muted-foreground text-center text-xs">
En attente du lancement par l'hôte
</p>
)
) : (
<BlindtestLobby
snapshot={snapshot}
isHost={isHost}
mode={blindtestMode}
tracksPerPlayer={tracksPerPlayer}
myCount={
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
}
totalTracks={totalTracks}
busy={busy}
onConfig={updateSettings}
onStart={() =>
start(
Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
)
}
/>
)} )}
{error && <p className="text-destructive text-center text-sm">{error}</p>} {isHost && showBlindtest && (
</div>
)
}
function BlindtestLobby({
isHost,
mode,
tracksPerPlayer,
myCount,
totalTracks,
busy,
onConfig,
onStart,
}: {
snapshot: RoomSnapshot
isHost: boolean
mode: BlindtestMode
tracksPerPlayer: number
myCount: number
totalTracks: number
busy: boolean
onConfig: (partial: {
blindtestMode?: BlindtestMode
tracksPerPlayer?: number
}) => void
onStart: () => void
}) {
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("")
setFeedback(null)
} else {
setFeedback(res.reason ?? "Refusé")
}
setSubmitting(false)
}
return (
<section className="flex flex-col gap-4">
{isHost && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Mode</span> <span className="text-sm font-medium">Mode blindtest</span>
<div className="flex gap-1"> <div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => ( {(["title_artist", "who_added", "mixed"] as const).map((m) => (
<Button <Button
key={m} key={m}
size="sm" size="sm"
className="flex-1" className="flex-1"
variant={mode === m ? "default" : "secondary"} variant={blindtestMode === m ? "default" : "secondary"}
onClick={() => onConfig({ blindtestMode: m })} onClick={() => updateSettings({ blindtestMode: m })}
> >
{MODE_LABELS[m]} {MODE_LABELS[m]}
</Button> </Button>
@ -219,7 +175,7 @@ function BlindtestLobby({
key={n} key={n}
size="sm" size="sm"
variant={tracksPerPlayer === n ? "default" : "secondary"} variant={tracksPerPlayer === n ? "default" : "secondary"}
onClick={() => onConfig({ tracksPerPlayer: n })} onClick={() => updateSettings({ tracksPerPlayer: n })}
> >
{n} {n}
</Button> </Button>
@ -229,6 +185,57 @@ function BlindtestLobby({
</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"> <div className="flex flex-col gap-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Tes titres ({myCount}/{tracksPerPlayer}) Tes titres ({myCount}/{tracksPerPlayer})
@ -261,16 +268,5 @@ function BlindtestLobby({
</ul> </ul>
)} )}
</div> </div>
{isHost ? (
<Button disabled={busy || totalTracks === 0} onClick={onStart}>
{busy ? "Lancement…" : `Lancer (${totalTracks} titres)`}
</Button>
) : (
<p className="text-muted-foreground text-center text-xs">
En attente du lancement par l'hôte ({totalTracks} titres soumis)
</p>
)}
</section>
) )
} }

View file

@ -1,19 +1,26 @@
import { useState } from "react"
import type { import type {
QuizPerPlayerResult, QuizPerPlayerResult,
QuizQuestionPayload, QuizQuestionPayload,
QuizRevealTruth, QuizRevealTruth,
RoomSnapshot, RoomSnapshot,
} from "@nerdware/shared" } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Countdown } from "@/components/countdown" import { Countdown } from "@/components/countdown"
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"
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round) const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal) const reveal = useRoomStore((s) => s.reveal)
const voteProgress = useRoomStore((s) => s.voteProgress) const voteProgress = useRoomStore((s) => s.voteProgress)
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex) const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
const hasVoted = useRoomStore((s) => s.hasVoted)
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const vote = useRoomStore((s) => s.vote) const vote = useRoomStore((s) => s.vote)
const voteText = useRoomStore((s) => s.voteText)
if (!round) { if (!round) {
return ( return (
@ -24,6 +31,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const question = round.payload as QuizQuestionPayload const question = round.payload as QuizQuestionPayload
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
const showReveal = truth !== null const showReveal = truth !== null
const isFree = question.format === "free"
const myResult = reveal const myResult = reveal
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""] ? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
: undefined : undefined
@ -66,21 +74,28 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
{question.prompt} {question.prompt}
</h2> </h2>
{isFree ? (
<FreeAnswer
disabled={showReveal || hasVoted}
onSubmit={voteText}
/>
) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{question.choices.map((choice, index) => ( {(question.choices ?? []).map((choice, index) => (
<button <button
key={index} key={index}
className={choiceClass(index)} className={choiceClass(index)}
disabled={showReveal || myChoiceIndex !== null} disabled={showReveal || hasVoted}
onClick={() => vote(index)} onClick={() => vote(index)}
> >
{choice} {choice}
</button> </button>
))} ))}
</div> </div>
)}
{!showReveal && {!showReveal &&
(myChoiceIndex !== null ? ( (hasVoted ? (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
Réponse envoyée en attente des autres Réponse envoyée en attente des autres
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
@ -94,16 +109,49 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
))} ))}
{showReveal && ( {showReveal && (
<div className="flex flex-col items-center gap-1">
{isFree && (
<p className="text-sm">
<span className="text-muted-foreground">Réponse : </span>
<span className="font-medium">{truth?.answer}</span>
</p>
)}
<p <p
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`} className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
> >
{myResult?.correct {myResult?.correct
? "Bonne réponse ! 🎉" ? "Bonne réponse ! 🎉"
: myResult?.choiceIndex == null : !hasVoted
? "Pas de réponse 😴" ? "Pas de réponse 😴"
: "Raté 💥"} : "Raté 💥"}
</p> </p>
</div>
)} )}
</div> </div>
) )
} }
function FreeAnswer({
disabled,
onSubmit,
}: {
disabled: boolean
onSubmit: (text: string) => void
}) {
const [text, setText] = useState("")
return (
<div className="flex gap-2">
<input
className={inputClass}
placeholder="Ta réponse"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)}
/>
<Button disabled={disabled || !text.trim()} onClick={() => onSubmit(text)}>
Valider
</Button>
</div>
)
}

View file

@ -1,30 +1,74 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import type { RoundType } from "@nerdware/shared"
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur) const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
// Animations custom déposées par l'utilisateur (gif/webp/apng/png/mp4...). // Médias custom déposés par l'utilisateur, rangés par mode :
// Glisse simplement des fichiers dans src/assets/transitions/ : ils sont // src/assets/transitions/quiz/* → transitions de quiz
// détectés au build et joués aléatoirement. Sinon, fallback animation Framer. // src/assets/transitions/blindtest/* → transitions de blindtest
const CUSTOM = Object.values( // src/assets/transitions/* → communs (fallback)
import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", { // Détectés au build ; sinon, animation Framer par défaut.
eager: true, const ALL_MEDIA = import.meta.glob(
import: "default", "../assets/transitions/**/*.{gif,webp,apng,png,avif}",
}) { eager: true, import: "default" }
) as string[] ) as Record<string, string>
const QUIZ_MEDIA: string[] = []
const BLINDTEST_MEDIA: string[] = []
const SHARED_MEDIA: string[] = []
for (const [path, url] of Object.entries(ALL_MEDIA)) {
if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url)
else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.push(url)
else SHARED_MEDIA.push(url)
}
const THEME: Record<
RoundType,
{ gradient: string; accent: string; label: string; kind: string; emoji: string }
> = {
quiz: {
gradient: "from-indigo-500 via-blue-600 to-cyan-600",
accent: "text-cyan-300",
label: "QUIZ",
kind: "Question",
emoji: "🧠",
},
blindtest: {
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
accent: "text-yellow-300",
label: "BLINDTEST",
kind: "Titre",
emoji: "🎧",
},
}
function mediaFor(type: RoundType): string[] {
const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA
return [...own, ...SHARED_MEDIA]
}
/** /**
* Transition "WarioWare" jouée entre les manches : wipe coloré plein écran, * Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand
* média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie. * il change (quiz blindtest), sinon affiche un teaser léger de la manche.
* Montée avec une `key` qui change rejoue à chaque manche. * Montée avec une `key` qui change rejoue à chaque manche.
*/ */
export function RoundTransition({ index }: { index: number }) { export function RoundTransition({
type,
index,
modeChanged,
}: {
type: RoundType
index: number
modeChanged: boolean
}) {
const [show, setShow] = useState(true) const [show, setShow] = useState(true)
// Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre). const theme = THEME[type]
const [media] = useState(() => const [media] = useState(() => {
CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null const pool = mediaFor(type)
) return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
})
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setShow(false), VISIBLE_MS) const t = setTimeout(() => setShow(false), VISIBLE_MS)
@ -36,7 +80,7 @@ export function RoundTransition({ index }: { index: number }) {
{show && ( {show && (
<motion.div <motion.div
key="round-transition" key="round-transition"
className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br from-fuchsia-500 via-purple-600 to-indigo-700" className={`pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`}
initial={{ clipPath: "inset(0 0 100% 0)" }} initial={{ clipPath: "inset(0 0 100% 0)" }}
animate={{ clipPath: "inset(0 0 0% 0)" }} animate={{ clipPath: "inset(0 0 0% 0)" }}
exit={{ exit={{
@ -45,17 +89,6 @@ export function RoundTransition({ index }: { index: number }) {
}} }}
transition={{ duration: 0.25, ease: "easeOut" }} transition={{ duration: 0.25, ease: "easeOut" }}
> >
{media ? (
<motion.img
src={media}
alt=""
className="max-h-[70vh] max-w-[80vw] object-contain"
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
/>
) : (
<>
<motion.div <motion.div
aria-hidden aria-hidden
className="absolute size-[220vmax] opacity-60" className="absolute size-[220vmax] opacity-60"
@ -67,6 +100,16 @@ export function RoundTransition({ index }: { index: number }) {
transition={{ duration: 7, repeat: Infinity, ease: "linear" }} transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
/> />
{media ? (
<motion.img
src={media}
alt=""
className="relative max-h-[70vh] max-w-[80vw] object-contain"
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
/>
) : (
<motion.div <motion.div
className="relative flex flex-col items-center" className="relative flex flex-col items-center"
initial={{ scale: 0, rotate: -12 }} initial={{ scale: 0, rotate: -12 }}
@ -78,11 +121,22 @@ export function RoundTransition({ index }: { index: number }) {
ease: "easeOut", ease: "easeOut",
}} }}
> >
{modeChanged ? (
<>
<span className="text-[18vmin] leading-none">{theme.emoji}</span>
<span
className={`font-heading text-[12vmin] leading-none font-black tracking-tight ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
>
{theme.label}
</span>
</>
) : (
<>
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow"> <span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
Question {theme.kind}
</span> </span>
<motion.span <motion.span
className="font-heading text-[26vmin] leading-none font-black text-yellow-300 drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]" className={`font-heading text-[26vmin] leading-none font-black ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
animate={{ scale: [1, 1.07, 1] }} animate={{ scale: [1, 1.07, 1] }}
transition={{ transition={{
duration: 0.4, duration: 0.4,
@ -92,6 +146,8 @@ export function RoundTransition({ index }: { index: number }) {
> >
{index + 1} {index + 1}
</motion.span> </motion.span>
</>
)}
<motion.span <motion.span
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@ -101,7 +157,6 @@ export function RoundTransition({ index }: { index: number }) {
PRÊT ?! PRÊT ?!
</motion.span> </motion.span>
</motion.div> </motion.div>
</>
)} )}
</motion.div> </motion.div>
)} )}

View file

@ -18,6 +18,7 @@ export function RoomPage({ code }: { code: string }) {
const roundKey = useRoomStore((s) => s.roundKey) const roundKey = useRoomStore((s) => s.roundKey)
const voteProgress = useRoomStore((s) => s.voteProgress) const voteProgress = useRoomStore((s) => s.voteProgress)
const round = useRoomStore((s) => s.round) const round = useRoomStore((s) => s.round)
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
if (!snapshot || snapshot.code !== code) { if (!snapshot || snapshot.code !== code) {
@ -70,8 +71,13 @@ export function RoomPage({ code }: { code: string }) {
</div> </div>
{boomKey > 0 && <Boom key={boomKey} />} {boomKey > 0 && <Boom key={boomKey} />}
{roundKey > 0 && ( {roundKey > 0 && round && (
<RoundTransition key={roundKey} index={snapshot.currentRound} /> <RoundTransition
key={roundKey}
type={round.type}
index={snapshot.currentRound}
modeChanged={roundModeChanged}
/>
)} )}
</div> </div>
) )

View file

@ -48,6 +48,8 @@ interface RoomState {
mediaSync: MediaSyncPayload | null mediaSync: MediaSyncPayload | null
boomKey: number boomKey: number
roundKey: number roundKey: number
/** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */
roundModeChanged: boolean
createRoom: (playerName: string) => Promise<RoomCreatedPayload> createRoom: (playerName: string) => Promise<RoomCreatedPayload>
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload> joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
@ -55,6 +57,7 @@ interface RoomState {
startGame: (rounds: RoundConfig[]) => Promise<void> startGame: (rounds: RoundConfig[]) => Promise<void>
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload> submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
vote: (choiceIndex: number) => void vote: (choiceIndex: number) => void
voteText: (text: string) => void
voteBlindtest: (answer: BlindtestAnswer) => void voteBlindtest: (answer: BlindtestAnswer) => void
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
boom: () => void boom: () => void
@ -77,6 +80,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
mediaSync: null, mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
roundModeChanged: false,
createRoom: (playerName) => createRoom: (playerName) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@ -141,6 +145,14 @@ export const useRoomStore = create<RoomState>((set, get) => ({
socket.emit("round:vote", { answer: { choiceIndex } }) socket.emit("round:vote", { answer: { choiceIndex } })
}, },
voteText: (text) => {
if (get().hasVoted || text.trim().length === 0) {
return
}
set({ hasVoted: true })
socket.emit("round:vote", { answer: { text: text.trim() } })
},
voteBlindtest: (answer) => { voteBlindtest: (answer) => {
if (get().hasVoted) { if (get().hasVoted) {
return return
@ -170,6 +182,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
mediaSync: null, mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
roundModeChanged: false,
}), }),
})) }))
@ -194,6 +207,7 @@ socket.on("round:start", (payload) =>
hasVoted: false, hasVoted: false,
mediaSync: null, mediaSync: null,
roundKey: s.roundKey + 1, roundKey: s.roundKey + 1,
roundModeChanged: (s.round?.type ?? null) !== payload.type,
})) }))
) )
socket.on("round:voteAck", (voteProgress) => socket.on("round:voteAck", (voteProgress) =>

View file

@ -7,6 +7,9 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
/** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */ /** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */
export type RoundType = "blindtest" | "quiz" export type RoundType = "blindtest" | "quiz"
/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */
export type GameType = "mixed" | "quiz" | "blindtest"
/** Modes de blindtest, déterminent la forme du vote et le barème. */ /** Modes de blindtest, déterminent la forme du vote et le barème. */
export type BlindtestMode = "title_artist" | "who_added" | "mixed" export type BlindtestMode = "title_artist" | "who_added" | "mixed"
@ -28,7 +31,7 @@ export interface RoundConfig {
/** Réglages de la room, modifiables dans le lobby par l'hôte. */ /** Réglages de la room, modifiables dans le lobby par l'hôte. */
export interface RoomSettings { export interface RoomSettings {
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */ /** Type de partie choisi dans le lobby (pilote l'UI partagée). */
gameType: RoundType gameType: GameType
blindtestMode: BlindtestMode blindtestMode: BlindtestMode
/** Durée d'une manche en secondes (def. 60). */ /** Durée d'une manche en secondes (def. 60). */
roundDuration: number roundDuration: number
@ -40,7 +43,7 @@ export interface RoomSettings {
/** Réglages par défaut d'une nouvelle room. */ /** Réglages par défaut d'une nouvelle room. */
export const DEFAULT_ROOM_SETTINGS: RoomSettings = { export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
gameType: "quiz", gameType: "mixed",
blindtestMode: "title_artist", blindtestMode: "title_artist",
roundDuration: 60, roundDuration: 60,
tracksPerPlayer: 2, tracksPerPlayer: 2,

View file

@ -4,6 +4,7 @@
import type { import type {
Answer, Answer,
BlindtestMode, BlindtestMode,
GameType,
PlayerScore, PlayerScore,
RoomSnapshot, RoomSnapshot,
RoundConfig, RoundConfig,
@ -27,7 +28,7 @@ export interface RoomJoinPayload {
} }
export interface UpdateSettingsPayload { export interface UpdateSettingsPayload {
gameType: RoundType gameType: GameType
blindtestMode: BlindtestMode blindtestMode: BlindtestMode
roundDuration: number roundDuration: number
tracksPerPlayer: number tracksPerPlayer: number

View file

@ -8,8 +8,8 @@ import type { QuizFormat } from "./domain"
export interface QuizQuestionPayload { export interface QuizQuestionPayload {
format: QuizFormat format: QuizFormat
prompt: string prompt: string
/** Choix proposés (truefalse = ["Vrai", "Faux"]). */ /** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */
choices: string[] choices?: string[]
category?: string category?: string
/** 1 (facile) .. 3 (difficile). */ /** 1 (facile) .. 3 (difficile). */
difficulty?: number difficulty?: number
@ -17,7 +17,10 @@ export interface QuizQuestionPayload {
/** Vérité révélée à tous (round:reveal → truth). */ /** Vérité révélée à tous (round:reveal → truth). */
export interface QuizRevealTruth { export interface QuizRevealTruth {
correctIndex: number /** Index de la bonne réponse (mcq/truefalse). */
correctIndex?: number
/** Réponse canonique affichée (free). */
answer?: string
} }
/** Résultat d'un joueur sur la manche. */ /** Résultat d'un joueur sur la manche. */