diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts index 92eec3d..b9512f2 100644 --- a/apps/server/src/db/quiz-repo.ts +++ b/apps/server/src/db/quiz-repo.ts @@ -1,6 +1,6 @@ // 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 { quizCategory, quizPlayed, quizQuestion } from "./schema" import type { QuizQuestion } from "../game/modes/quiz/questions" @@ -28,6 +28,7 @@ export async function loadQuizPool( prompt: quizQuestion.prompt, choices: quizQuestion.choices, correctIndex: quizQuestion.correctIndex, + acceptedAnswers: quizQuestion.acceptedAnswers, difficulty: quizQuestion.difficulty, category: quizCategory.name, }) @@ -35,8 +36,7 @@ export async function loadQuizPool( .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .where( and( - inArray(quizQuestion.format, ["mcq", "truefalse"]), - isNotNull(quizQuestion.correctIndex), + inArray(quizQuestion.format, ["mcq", "truefalse", "free"]), notInArray(quizQuestion.id, alreadyPlayed) ) ) @@ -44,13 +44,18 @@ export async function loadQuizPool( .limit(limit) 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) => ({ id: r.id, format: r.format as QuizQuestion["format"], prompt: r.prompt, - choices: r.choices as string[], - correctIndex: r.correctIndex as number, + choices: r.choices ?? undefined, + correctIndex: r.correctIndex ?? undefined, + acceptedAnswers: r.acceptedAnswers ?? undefined, category: r.category ?? "Quiz", difficulty: r.difficulty, })) diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts index 019a499..e1f9579 100644 --- a/apps/server/src/db/seed.ts +++ b/apps/server/src/db/seed.ts @@ -179,6 +179,7 @@ async function seedManual(): Promise { source: "manual", choices: q.choices, correctIndex: q.correctIndex, + acceptedAnswers: q.acceptedAnswers, }, ]) } diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 3bef317..8fe2b74 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -2,7 +2,12 @@ // pour chaque manche start → (votes | timer) → reveal → score, puis game:end. // Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté. -import type { Answer, PlayerScore, RoundConfig } from "@nerdware/shared" +import type { + Answer, + MediaControlPayload, + PlayerScore, + RoundConfig, +} from "@nerdware/shared" import type { IoServer } from "../socket" import type { RoomManager, ServerRoom } from "../rooms" import { createRound as defaultCreateRound, type RoundFactory } from "./registry" @@ -11,6 +16,8 @@ import type { GameRound, RoomGameController, RoundContext } from "./round" interface RoundRuntime { round: GameRound djId: string | null + /** Contributeur (blindtest) : reçoit round:start privé et ne vote pas. */ + secretPlayerId: string | null votes: Map startedAt: number endsAt: number @@ -65,7 +72,18 @@ export class GameEngine implements RoomGameController { } this.room.status = "ended" this.broadcastState() - this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() }) + // Récap des titres blindtest joués (pour récupérer les liens YouTube). + const tracks = this.room.blindtestTracks.map((t) => ({ + title: t.title, + artist: t.artist, + youtubeId: t.youtubeId, + url: t.url, + submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?", + })) + this.io.to(this.room.code).emit("game:end", { + finalScores: this.scoreboard(), + tracks: tracks.length > 0 ? tracks : undefined, + }) } /** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */ @@ -87,6 +105,22 @@ export class GameEngine implements RoomGameController { } } + /** Relais média : seul le DJ de la manche peut piloter la lecture. */ + handleMediaControl(playerId: string, payload: MediaControlPayload): void { + if ( + !this.runtime || + this.room.status !== "in_round" || + playerId !== this.runtime.djId + ) { + return + } + this.io.to(this.room.code).emit("media:sync", { + action: payload.action, + positionSec: payload.positionSec, + atServerTs: this.now(), + }) + } + private async playRound(config: RoundConfig): Promise { const round = this.createRound(config.type) const start = round.start(this.room) @@ -99,6 +133,7 @@ export class GameEngine implements RoomGameController { this.runtime = { round, djId: start.djId ?? null, + secretPlayerId: start.secretPlayerId ?? null, votes: new Map(), startedAt, endsAt, @@ -106,13 +141,24 @@ export class GameEngine implements RoomGameController { } this.room.status = "in_round" this.broadcastState() - this.io.to(this.room.code).emit("round:start", { + const base = { type: round.type, djId: this.runtime.djId ?? undefined, startsAt: startedAt, endsAt, payload: start.payload, - }) + } + // Le contributeur reçoit un round:start privé (mine: true) ; il reste secret + // pour les autres (qui ne reçoivent que `base`). + const secretSocketId = this.runtime.secretPlayerId + ? this.room.players.get(this.runtime.secretPlayerId)?.socketId + : null + if (secretSocketId) { + this.io.to(secretSocketId).emit("round:start", { ...base, mine: true }) + this.io.to(this.room.code).except(secretSocketId).emit("round:start", base) + } else { + this.io.to(this.room.code).emit("round:start", base) + } // Attend la première condition de fin : timer écoulé OU tous votés. await new Promise((resolve) => { @@ -156,10 +202,14 @@ export class GameEngine implements RoomGameController { } } - /** Votants éligibles : tous les joueurs connectés (le DJ inclus). */ + /** + * Votants éligibles : joueurs connectés (DJ neutre inclus), hors contributeur + * (blindtest) qui n'a rien à saisir. + */ private eligibleVoters(): string[] { + const secret = this.runtime?.secretPlayerId return [...this.room.players.values()] - .filter((p) => p.connected) + .filter((p) => p.connected && p.name !== "" && p.id !== secret) .map((p) => p.id) } diff --git a/apps/server/src/game/match.ts b/apps/server/src/game/match.ts new file mode 100644 index 0000000..235cd99 --- /dev/null +++ b/apps/server/src/game/match.ts @@ -0,0 +1,54 @@ +// Matching tolérant pour les réponses libres (titre/artiste). +// Normalisation (minuscules, accents, ponctuation) + distance de Levenshtein. + +/** Minuscule, sans accents, sans ponctuation, espaces compactés. */ +export function normalize(text: string): string { + return text + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") // accents + .replace(/\(.*?\)|\[.*?\]/g, " ") // contenu entre parenthèses/crochets + .replace(/[^a-z0-9\s]/g, " ") // ponctuation + .replace(/\s+/g, " ") + .trim() +} + +/** Distance d'édition de Levenshtein. */ +export function levenshtein(a: string, b: string): number { + if (a === b) return 0 + if (a.length === 0) return b.length + if (b.length === 0) return a.length + let prev = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + const curr = [i] + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) + } + prev = curr + } + return prev[b.length] +} + +/** + * Vrai si `guess` correspond à `truth` avec tolérance : + * - égalité après normalisation, ou + * - une normalisation contient l'autre (réponse partielle), ou + * - distance de Levenshtein ≤ ~20% de la longueur (fautes de frappe). + */ +export function fuzzyMatch(guess: string, truth: string): boolean { + const g = normalize(guess) + const t = normalize(truth) + if (!g || !t) { + return false + } + if (g === t) { + return true + } + // Réponse contenue (ex: "zelda" pour "the legend of zelda"). + if (g.length >= 3 && (t.includes(g) || g.includes(t))) { + return true + } + const tolerance = Math.floor(Math.max(g.length, t.length) * 0.2) + return levenshtein(g, t) <= tolerance +} diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts new file mode 100644 index 0000000..cdeb1ee --- /dev/null +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { RoomManager, type BlindtestTrack } from "../../../rooms" +import type { RoundContext } from "../../round" +import { BlindtestRound } from "./blindtest-round" +import { prepareBlindtestForRoom } from "./pool" +import { fuzzyMatch, normalize } from "../../match" + +function track(submittedBy: string): BlindtestTrack { + return { + id: "t1", + youtubeId: "abc12345678", + url: "https://youtu.be/abc12345678", + title: "The Legend of Zelda Main Theme", + artist: "Koji Kondo", + submittedBy, + } +} + +function ctxFor( + room: ReturnType["room"], + t: BlindtestTrack, + mode: "title_artist" | "who_added" | "mixed" +): RoundContext { + room.settings.blindtestMode = mode + return { + room, + djId: null, + votes: new Map(), + startedAt: 0, + endsAt: 10_000, + data: { track: t }, + } +} + +describe("match", () => { + test("normalize retire accents/ponctuation/casse", () => { + expect(normalize("Pokémon: Rouge & Bleu!")).toBe("pokemon rouge bleu") + }) + test("fuzzyMatch tolère fautes et réponses partielles", () => { + expect(fuzzyMatch("zelda", "The Legend of Zelda")).toBe(true) + expect(fuzzyMatch("koji kondo", "Koji Kondo")).toBe(true) + expect(fuzzyMatch("mario", "The Legend of Zelda")).toBe(false) + }) +}) + +describe("BlindtestRound", () => { + test("start : le DJ est neutre (jamais le contributeur)", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + room.blindtestTracks = [track(a.id)] + prepareBlindtestForRoom(room) + + const round = new BlindtestRound() + const start = round.start(room) + expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice) + expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" }) + }) + + test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + const { player: c } = rooms.join(room.code, "Carol", "sc") + const t = track(a.id) // Alice = contributrice/DJ + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "who_added") + round.submitAnswer(ctx, b.id, { guessedPlayerId: c.id }) // faux + round.submitAnswer(ctx, c.id, { guessedPlayerId: a.id }) // juste + round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // contributrice : non scorée + const deltas = round.score(ctx) + // Carol devine juste → 100 ; Alice trompe Bob (1 mauvaise devinette) → 50 + expect(deltas).toContainEqual({ playerId: c.id, delta: 100 }) + expect(deltas).toContainEqual({ playerId: a.id, delta: 50 }) + expect(deltas.find((d) => d.playerId === b.id)).toBeUndefined() + }) + + test("who_added : points si on devine le bon contributeur", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + const t = track(a.id) + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "who_added") + round.submitAnswer(ctx, b.id, { guessedPlayerId: a.id }) // juste + const deltas = round.score(ctx) + expect(deltas).toEqual([{ playerId: b.id, delta: 100 }]) + }) + + test("title_artist : 60 titre + 40 artiste, fuzzy", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + const t = track(a.id) + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "title_artist") + round.submitAnswer(ctx, b.id, { title: "zelda", artist: "koji kondo" }) + const { perPlayerResult } = round.reveal(ctx) + expect(perPlayerResult[b.id]).toMatchObject({ + titleCorrect: true, + artistCorrect: true, + points: 100, + }) + expect(round.score(ctx)).toEqual([{ playerId: b.id, delta: 100 }]) + }) + + test("le contributeur ne marque pas sur son propre titre", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const t = track(a.id) + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "who_added") + round.submitAnswer(ctx, a.id, { guessedPlayerId: a.id }) + expect(round.score(ctx)).toEqual([]) + }) + + test("reveal expose le titre, l'artiste et le contributeur", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const t = track(a.id) + const round = new BlindtestRound() + const ctx = ctxFor(room, t, "title_artist") + const { truth } = round.reveal(ctx) + expect(truth.submittedBy).toBe(a.id) + expect(truth.submittedByName).toBe("Alice") + expect(truth.title).toBe("The Legend of Zelda Main Theme") + }) +}) diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts new file mode 100644 index 0000000..0fe58eb --- /dev/null +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -0,0 +1,155 @@ +// Épreuve Blindtest : un titre = une manche. Le contributeur du titre est le DJ +// (il pilote la lecture, son identité reste cachée aux autres). Il ne marque pas +// de points, SAUF en "qui l'a ajouté" : il gagne quand un joueur le devine mal. + +import type { + Answer, + BlindtestMode, + BlindtestPerPlayerResult, + BlindtestPlayerResult, + BlindtestRevealTruth, + BlindtestRoundPayload, + ScoreDelta, +} from "@nerdware/shared" +import type { GameRound, RoundContext, RoundStart } from "../../round" +import type { BlindtestTrack, ServerRoom } from "../../../rooms" +import { fuzzyMatch } from "../../match" +import { takeTrack } from "./pool" + +const TITLE_POINTS = 60 +const ARTIST_POINTS = 40 +const WHO_POINTS = 100 +/** Points gagnés par le contributeur pour chaque joueur qui le devine mal. */ +const MISDIRECTION_POINTS = 50 + +interface BlindtestRoundData { + track: BlindtestTrack +} + +/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */ +function pickDj(room: ServerRoom, submittedBy: string): string | null { + const eligible = [...room.players.values()].filter( + (p) => p.connected && p.id !== submittedBy + ) + if (eligible.length === 0) { + return null + } + return eligible[Math.floor(Math.random() * eligible.length)].id +} + +/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */ +function voterResult( + mode: BlindtestMode, + answer: Answer | undefined, + track: BlindtestTrack +): BlindtestPlayerResult { + const a = (answer ?? {}) as { + title?: string + artist?: string + guessedPlayerId?: string + } + let points = 0 + const result: BlindtestPlayerResult = { points: 0 } + + if (mode === "title_artist" || mode === "mixed") { + result.titleCorrect = a.title ? fuzzyMatch(a.title, track.title) : false + result.artistCorrect = a.artist ? fuzzyMatch(a.artist, track.artist) : false + if (result.titleCorrect) points += TITLE_POINTS + if (result.artistCorrect) points += ARTIST_POINTS + } + if (mode === "who_added" || mode === "mixed") { + result.guessedCorrect = a.guessedPlayerId === track.submittedBy + if (result.guessedCorrect) points += WHO_POINTS + } + + result.points = points + return result +} + +export class BlindtestRound implements GameRound { + readonly type = "blindtest" as const + + start(room: ServerRoom): RoundStart { + const track = takeTrack(room) + // DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle. + const djId = track ? pickDj(room, track.submittedBy) : null + const payload: BlindtestRoundPayload | null = track + ? { trackId: track.id, youtubeId: track.youtubeId } + : null + return { + djId, + payload, + secretPlayerId: track?.submittedBy, + data: track ? { track } : null, + } + } + + submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { + // Premier vote verrouillé (idempotent). Le contributeur peut voter mais son + // vote ne lui rapporte rien (voir buildResults) ; ça évite de bloquer la fin. + if (ctx.votes.has(playerId)) { + return + } + ctx.votes.set(playerId, answer) + } + + /** Résultat par joueur (votants + bonus de tromperie du contributeur). */ + private buildResults(ctx: RoundContext): BlindtestPerPlayerResult { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const perPlayer: BlindtestPerPlayerResult = {} + + let wrongGuessers = 0 + for (const player of ctx.room.players.values()) { + if (player.id === track.submittedBy) { + continue + } + const result = voterResult(mode, ctx.votes.get(player.id), track) + perPlayer[player.id] = result + if ( + (mode === "who_added" || mode === "mixed") && + result.guessedCorrect === false + ) { + wrongGuessers++ + } + } + + // Contributeur : pas de points hors who_added/mixed ; sinon tromperie. + const misdirection = + mode === "who_added" || mode === "mixed" + ? wrongGuessers * MISDIRECTION_POINTS + : 0 + perPlayer[track.submittedBy] = { points: misdirection } + + return perPlayer + } + + reveal(ctx: RoundContext): { + truth: BlindtestRevealTruth + perPlayerResult: BlindtestPerPlayerResult + } { + const { track } = ctx.data as BlindtestRoundData + const submitter = ctx.room.players.get(track.submittedBy) + return { + truth: { + title: track.title, + artist: track.artist, + youtubeId: track.youtubeId, + submittedBy: track.submittedBy, + submittedByName: submitter?.name ?? "?", + }, + perPlayerResult: this.buildResults(ctx), + } + } + + score(ctx: RoundContext): ScoreDelta[] { + const perPlayer = this.buildResults(ctx) + const deltas: ScoreDelta[] = [] + for (const [playerId, result] of Object.entries(perPlayer)) { + if (result.points > 0) { + deltas.push({ playerId, delta: result.points }) + } + } + return deltas + } +} diff --git a/apps/server/src/game/modes/blindtest/index.ts b/apps/server/src/game/modes/blindtest/index.ts new file mode 100644 index 0000000..ddf4437 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/index.ts @@ -0,0 +1,6 @@ +import { registerRound } from "../../registry" +import { BlindtestRound } from "./blindtest-round" + +registerRound("blindtest", () => new BlindtestRound()) + +export { BlindtestRound } diff --git a/apps/server/src/game/modes/blindtest/pool.ts b/apps/server/src/game/modes/blindtest/pool.ts new file mode 100644 index 0000000..a86dd96 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/pool.ts @@ -0,0 +1,26 @@ +// File d'attente des titres blindtest par room, préchargée (mélangée) au +// lancement de la partie. Le BlindtestRound y pioche un titre par manche. + +import type { BlindtestTrack, ServerRoom } from "../../../rooms" + +const queueByRoom = new WeakMap() + +function shuffle(items: T[]): T[] { + const arr = [...items] + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +/** Mélange les titres soumis dans une file pour la partie. */ +export function prepareBlindtestForRoom(room: ServerRoom): void { + queueByRoom.set(room, shuffle(room.blindtestTracks)) +} + +/** Pioche le prochain titre (ou null si la file est vide). */ +export function takeTrack(room: ServerRoom): BlindtestTrack | null { + const queue = queueByRoom.get(room) + return queue && queue.length > 0 ? (queue.shift() ?? null) : null +} diff --git a/apps/server/src/game/modes/blindtest/youtube.test.ts b/apps/server/src/game/modes/blindtest/youtube.test.ts new file mode 100644 index 0000000..77ed095 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/youtube.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { extractYoutubeId } from "./youtube" + +describe("extractYoutubeId", () => { + test.each([ + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"], + ["dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ])("extrait l'ID de %s", (url, expected) => { + expect(extractYoutubeId(url)).toBe(expected) + }) + + test("renvoie null pour une URL non YouTube", () => { + expect(extractYoutubeId("https://example.com")).toBeNull() + expect(extractYoutubeId("")).toBeNull() + }) +}) diff --git a/apps/server/src/game/modes/blindtest/youtube.ts b/apps/server/src/game/modes/blindtest/youtube.ts new file mode 100644 index 0000000..dfdd65c --- /dev/null +++ b/apps/server/src/game/modes/blindtest/youtube.ts @@ -0,0 +1,53 @@ +// Extraction d'ID YouTube + vérification via oEmbed (existe + embeddable), +// qui fournit aussi titre et auteur pour le reveal. + +/** Extrait l'ID vidéo (11 chars) d'une URL YouTube (watch, youtu.be, shorts, embed). */ +export function extractYoutubeId(input: string): string | null { + const url = input.trim() + // ID brut directement collé. + if (/^[\w-]{11}$/.test(url)) { + return url + } + const patterns = [ + /[?&]v=([\w-]{11})/, // watch?v=ID + /youtu\.be\/([\w-]{11})/, // youtu.be/ID + /\/shorts\/([\w-]{11})/, // /shorts/ID + /\/embed\/([\w-]{11})/, // /embed/ID + ] + for (const re of patterns) { + const m = url.match(re) + if (m) { + return m[1] + } + } + return null +} + +export interface YoutubeMeta { + title: string + artist: string +} + +/** + * Vérifie via oEmbed que la vidéo existe et est intégrable, et renvoie + * { title, artist }. null si introuvable/privée/embedding désactivé. + */ +export async function fetchYoutubeMeta( + youtubeId: string +): Promise { + const url = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${youtubeId}&format=json` + try { + const res = await fetch(url) + if (!res.ok) { + // 401/404 → vidéo privée, supprimée, ou embedding désactivé. + return null + } + const json = (await res.json()) as { title?: string; author_name?: string } + return { + title: json.title ?? "", + artist: json.author_name ?? "", + } + } catch { + return null + } +} diff --git a/apps/server/src/game/modes/index.ts b/apps/server/src/game/modes/index.ts index 684f1f1..29146d8 100644 --- a/apps/server/src/game/modes/index.ts +++ b/apps/server/src/game/modes/index.ts @@ -2,13 +2,19 @@ // pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici. import "./quiz" +import "./blindtest" import type { ServerRoom } from "../../rooms" import { prepareQuizForRoom } from "./quiz/pool" +import { prepareBlindtestForRoom } from "./blindtest/pool" -/** Précharge le contenu nécessaire avant de lancer la partie (ex: pool quiz). */ +/** Précharge le contenu nécessaire avant de lancer la partie. */ export async function prepareRoom(room: ServerRoom): Promise { - if (room.settings.rounds.some((r) => r.type === "quiz")) { + const types = new Set(room.settings.rounds.map((r) => r.type)) + if (types.has("quiz")) { await prepareQuizForRoom(room) } + if (types.has("blindtest")) { + prepareBlindtestForRoom(room) + } } diff --git a/apps/server/src/game/modes/quiz/questions.ts b/apps/server/src/game/modes/quiz/questions.ts index 3c4af08..271157e 100644 --- a/apps/server/src/game/modes/quiz/questions.ts +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -8,8 +8,12 @@ export interface QuizQuestion { id: string format: QuizFormat prompt: string - choices: string[] - correctIndex: number + /** mcq/truefalse uniquement. */ + choices?: string[] + /** mcq/truefalse uniquement. */ + correctIndex?: number + /** free : réponses acceptées (matching tolérant). */ + acceptedAnswers?: string[] category: string difficulty: number } @@ -107,4 +111,45 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [ category: "Pop culture", 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, + }, ] diff --git a/apps/server/src/game/modes/quiz/quiz-round.test.ts b/apps/server/src/game/modes/quiz/quiz-round.test.ts index 6d5bbe6..399269a 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.test.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.test.ts @@ -2,11 +2,15 @@ import { describe, expect, test } from "bun:test" import { RoomManager } from "../../../rooms" import type { RoundContext } from "../../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 -function makeCtx(): { ctx: RoundContext; p1: string; p2: string } { +function makeCtx(q: QuizQuestion = question): { + ctx: RoundContext + p1: string + p2: string +} { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") const { player: b } = rooms.join(room.code, "Bob", "sb") @@ -16,7 +20,7 @@ function makeCtx(): { ctx: RoundContext; p1: string; p2: string } { votes: new Map(), startedAt: 0, endsAt: 10_000, - data: { question, votedAt: new Map() }, + data: { question: q, votedAt: new Map() }, } return { ctx, p1: a.id, p2: b.id } } @@ -56,4 +60,24 @@ describe("QuizRound", () => { const deltas = round.score(ctx) 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 }]) + }) }) diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index e8f121e..310b046 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -1,5 +1,5 @@ // É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 { Answer, @@ -10,6 +10,7 @@ import type { } from "@nerdware/shared" import { hasDb } from "../../../db" import { markQuestionPlayed } from "../../../db/quiz-repo" +import { fuzzyMatch } from "../../match" import type { GameRound, RoundContext, RoundStart } from "../../round" import type { ServerRoom } from "../../../rooms" import type { QuizQuestion } from "./questions" @@ -25,10 +26,29 @@ interface QuizRoundData { votedAt: Map } -function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } { - return ( - typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number" - ) +function asChoice(answer: Answer): number | null { + const v = (answer as { choiceIndex?: unknown }).choiceIndex + 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 { @@ -45,10 +65,12 @@ export class QuizRound implements GameRound { const payload: QuizQuestionPayload = { format: question.format, prompt: question.prompt, - choices: question.choices, category: question.category, difficulty: question.difficulty, } + if (question.format !== "free") { + payload.choices = question.choices + } const data: QuizRoundData = { question, votedAt: new Map() } return { djId: null, payload, data } } @@ -58,29 +80,42 @@ export class QuizRound implements GameRound { if (ctx.votes.has(playerId)) { return } - if (!isQuizAnswer(answer)) { - return - } const { question, votedAt } = ctx.data as QuizRoundData - if (answer.choiceIndex < 0 || answer.choiceIndex >= question.choices.length) { - return + if (question.format === "free") { + const text = asText(answer) + if (text === null || text.trim().length === 0) { + return + } + 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 }) } - ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex }) 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 perPlayerResult: QuizPerPlayerResult = {} for (const player of ctx.room.players.values()) { - const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined - const choiceIndex = vote ? vote.choiceIndex : null + const vote = ctx.votes.get(player.id) perPlayerResult[player.id] = { - choiceIndex, - correct: choiceIndex === question.correctIndex, + choiceIndex: vote ? asChoice(vote) : null, + 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[] { @@ -88,7 +123,7 @@ export class QuizRound implements GameRound { const total = ctx.endsAt - ctx.startedAt const deltas: ScoreDelta[] = [] for (const [playerId, vote] of ctx.votes) { - if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) { + if (!isCorrect(question, vote)) { continue } // Bonus rapidité : proportionnel au temps restant au moment du vote. diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts index 8f3ca62..9836f36 100644 --- a/apps/server/src/game/round.ts +++ b/apps/server/src/game/round.ts @@ -1,7 +1,13 @@ // Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface : // brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie. -import type { Answer, RevealPayload, RoundType, ScoreDelta } from "@nerdware/shared" +import type { + Answer, + MediaControlPayload, + RevealPayload, + RoundType, + ScoreDelta, +} from "@nerdware/shared" import type { ServerRoom } from "../rooms" /** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */ @@ -12,6 +18,11 @@ export interface RoundStart { durationSec?: number /** Payload broadcasté aux clients, SANS la réponse. */ payload: unknown + /** + * Joueur recevant un round:start privé avec `mine: true` et exclu des votants + * (blindtest : le contributeur du titre). Jamais révélé aux autres. + */ + secretPlayerId?: string /** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */ data?: unknown } @@ -52,4 +63,6 @@ export interface GameRound { export interface RoomGameController { run(): Promise handleVote(playerId: string, answer: Answer): void + /** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */ + handleMediaControl(playerId: string, payload: MediaControlPayload): void } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index c52c227..4b15c83 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -10,9 +10,14 @@ import type { } from "@nerdware/shared" import type { IoServer } from "./socket" import { RoomError, RoomManager, type ServerRoom } from "./rooms" +import { randomUUID } from "node:crypto" import { GameEngine } from "./game/engine" import { hasRound } from "./game/registry" import { prepareRoom } from "./game/modes" +import { + extractYoutubeId, + fetchYoutubeMeta, +} from "./game/modes/blindtest/youtube" type AppSocket = Socket< ClientToServerEvents, @@ -48,11 +53,8 @@ export function registerRoomHandlers( } socket.on("room:create", (payload, ack) => { - const name = cleanName(payload?.playerName) - if (!name) { - ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) - return - } + // Le pseudo est choisi dans la room (player:setName) : on crée sans nom. + const name = cleanName(payload?.playerName) ?? "" try { const { room, player } = rooms.create(name, socket.id) socket.data.playerId = player.id @@ -66,12 +68,8 @@ export function registerRoomHandlers( }) socket.on("room:join", (payload, ack) => { - const name = cleanName(payload?.playerName) + const name = cleanName(payload?.playerName) ?? "" const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : "" - if (!name) { - ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) - return - } try { const { room, player } = rooms.join(code, name, socket.id) socket.data.playerId = player.id @@ -84,6 +82,22 @@ export function registerRoomHandlers( } }) + socket.on("player:setName", (payload) => { + const code = socket.data.roomCode + const playerId = socket.data.playerId + const room = code ? rooms.get(code) : undefined + const name = cleanName(payload?.name) + if (!room || !playerId || !name) { + return + } + const player = room.players.get(playerId) + if (!player) { + return + } + player.name = name + broadcastState(room) + }) + socket.on("lobby:updateSettings", (payload) => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined @@ -91,8 +105,10 @@ export function registerRoomHandlers( return } room.settings = { + gameType: payload.gameType, blindtestMode: payload.blindtestMode, roundDuration: payload.roundDuration, + tracksPerPlayer: payload.tracksPerPlayer, rounds: payload.rounds, } broadcastState(room) @@ -121,6 +137,39 @@ export function registerRoomHandlers( }) return } + const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") + const connected = [...room.players.values()].filter( + (p) => p.connected && p.name !== "" + ).length + if (hasBlindtest && connected < 3) { + ack({ + ok: false, + error: { + code: "NEED_THREE", + message: "Le blindtest nécessite au moins 3 joueurs.", + }, + }) + return + } + if (hasBlindtest) { + const tpp = room.settings.tracksPerPlayer + const pending = [...room.players.values()].some( + (p) => + p.connected && + p.name !== "" && + room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp + ) + if (pending) { + ack({ + ok: false, + error: { + code: "TRACKS_PENDING", + message: "Tous les joueurs n'ont pas encore soumis leurs titres.", + }, + }) + return + } + } ack({ ok: true, data: null }) // Précharge le contenu (pool quiz depuis la DB), puis lance la boucle. // Fire-and-forget : la boucle de jeu broadcast son propre état. @@ -133,6 +182,88 @@ export function registerRoomHandlers( .catch((err) => console.error("[game] run failed", err)) }) + socket.on("blindtest:submitTrack", async (payload, ack) => { + const code = socket.data.roomCode + const playerId = socket.data.playerId + const room = code ? rooms.get(code) : undefined + if (!room || !playerId || room.status !== "lobby") { + ack({ accepted: false, reason: "Soumission impossible maintenant." }) + return + } + const youtubeId = extractYoutubeId(payload?.youtubeUrl ?? "") + if (!youtubeId) { + ack({ accepted: false, reason: "Lien YouTube invalide." }) + return + } + const mine = room.blindtestTracks.filter((t) => t.submittedBy === playerId) + if (mine.length >= room.settings.tracksPerPlayer) { + ack({ accepted: false, reason: "Quota de titres atteint." }) + return + } + if (room.blindtestTracks.some((t) => t.youtubeId === youtubeId)) { + ack({ accepted: false, reason: "Titre déjà soumis." }) + return + } + const meta = await fetchYoutubeMeta(youtubeId) + if (!meta) { + ack({ + accepted: false, + reason: "Vidéo introuvable ou non intégrable.", + }) + return + } + // Re-vérifie la room après l'await (le joueur a pu se déconnecter). + if (room.status !== "lobby" || !room.players.has(playerId)) { + ack({ accepted: false, reason: "Soumission impossible maintenant." }) + return + } + const trackId = randomUUID() + room.blindtestTracks.push({ + id: trackId, + youtubeId, + url: payload.youtubeUrl, + title: meta.title, + artist: meta.artist, + submittedBy: playerId, + }) + ack({ + accepted: true, + trackId, + youtubeId, + title: meta.title, + count: mine.length + 1, + }) + broadcastState(room) + }) + + socket.on("blindtest:removeTrack", (payload, ack) => { + const code = socket.data.roomCode + const playerId = socket.data.playerId + const room = code ? rooms.get(code) : undefined + if (!room || !playerId || room.status !== "lobby") { + ack({ ok: false }) + return + } + const before = room.blindtestTracks.length + room.blindtestTracks = room.blindtestTracks.filter( + (t) => !(t.id === payload.trackId && t.submittedBy === playerId) + ) + const removed = room.blindtestTracks.length < before + ack({ ok: removed }) + if (removed) { + broadcastState(room) + } + }) + + socket.on("media:control", (payload) => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || !room.game || !socket.data.playerId) { + return + } + room.game.handleMediaControl(socket.data.playerId, payload) + }) + socket.on("round:vote", (payload) => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined @@ -142,6 +273,22 @@ export function registerRoomHandlers( room.game.handleVote(socket.data.playerId, payload.answer) }) + socket.on("lobby:return", () => { + const code = socket.data.roomCode + const room = code ? rooms.get(code) : undefined + if (!room || room.hostId !== socket.data.playerId) { + return + } + // Rejouer : retour au lobby, scores remis à zéro, joueurs et titres conservés. + room.status = "lobby" + room.currentRound = -1 + room.game = null + for (const playerId of room.scores.keys()) { + room.scores.set(playerId, 0) + } + broadcastState(room) + }) + socket.on("disconnect", () => { const affected = rooms.handleDisconnect(socket.id) if (!affected) { diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index fadc8f7..96be50b 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -18,6 +18,16 @@ export interface ServerPlayer { socketId: string | null } +/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */ +export interface BlindtestTrack { + id: string + youtubeId: string + url: string + title: string + artist: string + submittedBy: string +} + /** Room côté serveur : état runtime complet (le snapshot public en dérive). */ export interface ServerRoom { code: string @@ -27,6 +37,8 @@ export interface ServerRoom { settings: RoomSettings scores: Map currentRound: number + /** Titres soumis pour le blindtest (mélangés au lancement). */ + blindtestTracks: BlindtestTrack[] /** Moteur de jeu actif (null tant qu'on est dans le lobby). */ game: RoomGameController | null } @@ -50,6 +62,7 @@ export class RoomManager { settings: { ...DEFAULT_ROOM_SETTINGS }, scores: new Map([[player.id, 0]]), currentRound: -1, + blindtestTracks: [], game: null, } this.rooms.set(code, room) @@ -99,22 +112,28 @@ export class RoomManager { /** Projette la room vers la vue publique diffusée aux clients (sans secrets). */ toSnapshot(room: ServerRoom): RoomSnapshot { + // Seuls les joueurs ayant choisi un pseudo sont visibles dans la room. + const named = [...room.players.values()].filter((p) => p.name !== "") return { code: room.code, status: room.status, hostId: room.hostId, - players: [...room.players.values()].map((p) => ({ + players: named.map((p) => ({ id: p.id, name: p.name, connected: p.connected, })), settings: room.settings, - scores: [...room.scores.entries()].map(([playerId, score]) => ({ - playerId, - score, + scores: named.map((p) => ({ + playerId: p.id, + score: room.scores.get(p.id) ?? 0, })), currentRound: room.currentRound, totalRounds: room.settings.rounds.length, + submissions: named.map((p) => ({ + playerId: p.id, + count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length, + })), } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3aca4dd..6a275ee 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,15 @@ import { Route, Switch } from "wouter" import { HomePage } from "@/pages/home" +import { JoinPage } from "@/pages/join" import { RoomPage } from "@/pages/room" export function App() { return ( + + {(params) => } + {(params) => } diff --git a/apps/web/src/assets/transitions/README.md b/apps/web/src/assets/transitions/README.md index eb8792a..a1675d9 100644 --- a/apps/web/src/assets/transitions/README.md +++ b/apps/web/src/assets/transitions/README.md @@ -3,9 +3,19 @@ Dépose ici des fichiers d'animation pour les transitions entre manches : formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`. -- Ils sont détectés automatiquement au build (`import.meta.glob`). -- À 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. +Rangement par mode (recommandé pour des transitions personnalisées) : + +- `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é plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx new file mode 100644 index 0000000..a96ea40 --- /dev/null +++ b/apps/web/src/components/blindtest-view.tsx @@ -0,0 +1,348 @@ +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { Disc3, Pause, Play, Rewind } from "lucide-react" +import type { + BlindtestAnswer, + BlindtestMode, + BlindtestPerPlayerResult, + BlindtestRevealTruth, + BlindtestRoundPayload, + RoomSnapshot, +} from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" +import { useYoutube, type YoutubeApi } from "@/lib/youtube" +import { Countdown } from "@/components/countdown" +import { Avatar } from "@/components/avatar" + +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" + +function fmt(s: number): string { + const m = Math.floor(s / 60) + const sec = Math.floor(s % 60) + return `${m}:${sec.toString().padStart(2, "0")}` +} + +export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { + const round = useRoomStore((s) => s.round) + const reveal = useRoomStore((s) => s.reveal) + const mediaSync = useRoomStore((s) => s.mediaSync) + const playerId = useRoomStore((s) => s.playerId) + const hasVoted = useRoomStore((s) => s.hasVoted) + const voteBlindtest = useRoomStore((s) => s.voteBlindtest) + const mediaControl = useRoomStore((s) => s.mediaControl) + + const track = round?.payload as BlindtestRoundPayload | undefined + const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "") + + const isDj = !!playerId && round?.djId === playerId + const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null + const showReveal = truth !== null + + // Non-DJ : on suit le DJ via media:sync (avec compensation de latence). + useEffect(() => { + if (isDj || !ready || !mediaSync) { + return + } + const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 + if (mediaSync.action === "play") { + api.seek(mediaSync.positionSec + Math.max(0, elapsed)) + api.play() + } else if (mediaSync.action === "pause") { + api.seek(mediaSync.positionSec) + api.pause() + } else { + api.seek(mediaSync.positionSec) + } + }, [mediaSync, isDj, ready, api]) + + // Au reveal, on coupe le son. + useEffect(() => { + if (showReveal && ready) { + api.pause() + } + }, [showReveal, ready, api]) + + return ( +
+
+ + Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds} + + {!showReveal && round && ( + + )} +
+ + {/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la + manche (son seulement), puis révélé (bannière visible) au reveal. */} +
+
+ {!showReveal && ( +
+ + + +
+ )} +
+ + {isDj && !showReveal && ( + + )} + + {!showReveal && + (round?.mine ? ( + + ) : ( + + ))} + + {showReveal && truth && ( + + )} +
+ ) +} + +function DjControls({ + ready, + api, + mediaControl, +}: { + ready: boolean + api: YoutubeApi + mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void +}) { + const [pos, setPos] = useState(0) + const [dur, setDur] = useState(0) + + useEffect(() => { + if (!ready) { + return + } + const id = setInterval(() => { + setPos(api.time()) + setDur(api.duration()) + }, 400) + return () => clearInterval(id) + }, [ready, api]) + + return ( +
+ + Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres) + +
+ + + +
+
+ {fmt(pos)} + { + const v = Number(e.target.value) + setPos(v) + api.seek(v) + mediaControl("seek", v) + }} + className="accent-primary flex-1" + /> + {fmt(dur)} +
+
+ ) +} + +function MisleadCard({ mode }: { mode: BlindtestMode }) { + return ( +
+ 🤫 +

C'est ton titre !

+

+ {mode === "title_artist" + ? "Tu ne votes pas. Savoure pendant que les autres cherchent." + : "Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé."} +

+
+ ) +} + +function VoteForm({ + mode, + snapshot, + playerId, + disabled, + onVote, +}: { + mode: BlindtestMode + snapshot: RoomSnapshot + playerId: string | null + disabled: boolean + onVote: (answer: BlindtestAnswer) => void +}) { + const [title, setTitle] = useState("") + const [artist, setArtist] = useState("") + const [guessed, setGuessed] = useState(null) + + const needsText = mode === "title_artist" || mode === "mixed" + const needsWho = mode === "who_added" || mode === "mixed" + const canSubmit = + !disabled && + (!needsText || title.trim().length > 0) && + (!needsWho || guessed !== null) + + function submit() { + const answer: { + title?: string + artist?: string + guessedPlayerId?: string + } = {} + if (needsText) { + answer.title = title.trim() + answer.artist = artist.trim() + } + if (needsWho && guessed) { + answer.guessedPlayerId = guessed + } + onVote(answer as BlindtestAnswer) + } + + if (disabled) { + return ( +

+ Réponse envoyée — en attente des autres +

+ ) + } + + return ( +
+ {needsText && ( + <> + setTitle(e.target.value)} + /> + setArtist(e.target.value)} + /> + + )} + {needsWho && ( +
+ {snapshot.players + .filter((p) => p.id !== playerId) + .map((p) => ( + + ))} +
+ )} + +
+ ) +} + +function RevealCard({ + truth, + result, +}: { + truth: BlindtestRevealTruth + result?: BlindtestPerPlayerResult[string] +}) { + return ( +
+

{truth.title}

+

{truth.artist}

+

+ Ajouté par + + {truth.submittedByName} +

+ {result && ( +

0 ? "text-green-500" : "text-red-500"}`} + > + {result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"} +

+ )} +
+ ) +} diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 28c1ae9..f071f52 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -1,6 +1,6 @@ import { Link } from "wouter" import { motion } from "framer-motion" -import { Crown } from "lucide-react" +import { Crown, ExternalLink, Music } from "lucide-react" import type { PlayerScore, RoomSnapshot } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" @@ -112,7 +112,10 @@ function PodiumColumn({ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const finalScores = useRoomStore((s) => s.finalScores) + const gameTracks = useRoomStore((s) => s.gameTracks) const reset = useRoomStore((s) => s.reset) + const returnToLobby = useRoomStore((s) => s.returnToLobby) + const isHost = snapshot.hostId === playerId const scores: PlayerScore[] = finalScores ?? snapshot.scores const ranked = [...scores].sort((a, b) => b.score - a.score) @@ -194,11 +197,62 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} - - - + {gameTracks && gameTracks.length > 0 && ( +
+

+ Les musiques de la partie +

+
    + {gameTracks.map((t) => ( +
  • + + + + {t.title} + + + ajouté par {t.submittedByName} + + + + + +
  • + ))} +
+
+ )} + +
+ {isHost ? ( + + ) : ( +

+ En attente que l'hôte relance une partie… +

+ )} + + + +
) } diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index b315a3f..a34afb0 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,25 +1,156 @@ import { useState } from "react" -import type { RoomSnapshot } from "@nerdware/shared" +import { + Brain, + Headphones, + ListMusic, + Minus, + Music, + Play, + Plus, + Shuffle, + Trash2, + type LucideIcon, +} from "lucide-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 MAX_TRACKS = 10 +const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [ + { value: "mixed", label: "Mixte", Icon: Shuffle }, + { value: "quiz", label: "Quiz", Icon: Brain }, + { value: "blindtest", label: "Blindtest", Icon: Headphones }, +] +const MODE_LABELS: Record = { + 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" + +function Stepper({ + value, + min = 1, + max = MAX_TRACKS, + onChange, +}: { + value: number + min?: number + max?: number + onChange: (v: number) => void +}) { + const clamp = (v: number) => Math.max(min, Math.min(max, v)) + return ( +
+ + { + const n = parseInt(e.target.value, 10) + if (!Number.isNaN(n)) { + onChange(clamp(n)) + } + }} + className="border-input bg-background h-8 w-14 rounded-md border text-center text-sm [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none" + /> + +
+ ) +} + +function shuffle(items: T[]): T[] { + const arr = [...items] + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +/** 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 : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite. + const rounds: RoundConfig[] = [ + ...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })), + ...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })), + ] + return shuffle(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(null) - async function handleStart() { + const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) + const myCount = + snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0 + + // Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs). + const connectedCount = snapshot.players.filter((p) => p.connected).length + const blindtestAvailable = connectedCount >= 3 + // Tant que le blindtest est indisponible, on retombe sur du quiz (sans + // toucher au réglage stocké) : repasse en mixte automatiquement à 3 joueurs. + const effectiveType: GameType = blindtestAvailable ? gameType : "quiz" + const showQuiz = effectiveType === "quiz" || effectiveType === "mixed" + const showBlindtest = + effectiveType === "blindtest" || effectiveType === "mixed" + + const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks) + // Blindtest : tout le monde doit avoir soumis son quota de titres. + const allSubmitted = + !showBlindtest || + (snapshot.submissions.length > 0 && + snapshot.submissions.every((s) => s.count >= tracksPerPlayer)) + const canStart = rounds.length > 0 && allSubmitted + + async function start() { setError(null) setBusy(true) try { - await startGame(count) + await startGame(rounds) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") } finally { @@ -57,35 +188,212 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { - {isHost ? ( -
-
- Questions de quiz -
- {QUESTION_OPTIONS.map((n) => ( + {isHost && ( +
+
+ {GAME_TYPES.map(({ value, label, Icon }) => { + const needsThree = value !== "quiz" && !blindtestAvailable + return ( + ) + })} +
+ {!blindtestAvailable && ( +

+ Le blindtest se débloque à 3 joueurs. +

+ )} +
+ )} + + {isHost && showQuiz && ( +
+ + Questions de quiz + +
+ {QUESTION_OPTIONS.map((n) => ( + + ))} +
+
+ )} + + {isHost && showBlindtest && ( +
+
+ + Mode blindtest + +
+ {(["title_artist", "who_added", "mixed"] as const).map((m) => ( + ))}
-
+ )} + + {showBlindtest && ( + + )} + + {isHost ? ( +
+ - {error && ( -

{error}

+ {showBlindtest && !allSubmitted && ( +

+ En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "} + titre(s). +

)} -
+ ) : (

- En attente du lancement de la partie par l'hôte… + En attente du lancement par l'hôte… + {showBlindtest && ` (${totalTracks} titres soumis)`}

)} + + {error &&

{error}

} + + ) +} + +interface MyTrack { + trackId: string + title: string + youtubeId: string +} + +function TrackSubmission({ + tracksPerPlayer, + myCount, +}: { + tracksPerPlayer: number + myCount: number +}) { + const submitTrack = useRoomStore((s) => s.submitTrack) + const removeTrack = useRoomStore((s) => s.removeTrack) + const [url, setUrl] = useState("") + const [submitting, setSubmitting] = useState(false) + const [feedback, setFeedback] = useState(null) + const [tracks, setTracks] = useState([]) + const quotaReached = myCount >= tracksPerPlayer + + async function submit() { + if (!url.trim()) { + return + } + setSubmitting(true) + setFeedback(null) + const res = await submitTrack(url.trim()) + if (res.accepted && res.trackId && res.youtubeId) { + setTracks((t) => [ + ...t, + { trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! }, + ]) + setUrl("") + } else { + setFeedback(res.reason ?? "Refusé") + } + setSubmitting(false) + } + + async function remove(trackId: string) { + const ok = await removeTrack(trackId) + if (ok) { + setTracks((t) => t.filter((x) => x.trackId !== trackId)) + } + } + + return ( +
+ + Tes titres ({myCount}/{tracksPerPlayer}) + + + {tracks.length > 0 && ( +
    + {tracks.map((t) => ( +
  • + + {t.title} + +
  • + ))} +
+ )} + + {!quotaReached && ( +
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + +
+ )} + {feedback &&

{feedback}

}
) } diff --git a/apps/web/src/components/pseudo-screen.tsx b/apps/web/src/components/pseudo-screen.tsx new file mode 100644 index 0000000..5a11d8c --- /dev/null +++ b/apps/web/src/components/pseudo-screen.tsx @@ -0,0 +1,45 @@ +import { useState } from "react" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" +import { Avatar } from "@/components/avatar" + +const inputClass = + "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" + +/** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */ +export function PseudoScreen({ code }: { code: string }) { + const setName = useRoomStore((s) => s.setName) + const [name, setName_] = useState("") + const canSubmit = name.trim().length > 0 + + return ( +
+
+

+ Room {code} +

+ +

Choisis ton pseudo

+ setName_(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && canSubmit && setName(name)} + /> + +
+
+ ) +} diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index c4035da..ddc401a 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -1,19 +1,26 @@ +import { useState } from "react" import type { QuizPerPlayerResult, QuizQuestionPayload, QuizRevealTruth, RoomSnapshot, } from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" 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 }) { const round = useRoomStore((s) => s.round) const reveal = useRoomStore((s) => s.reveal) const voteProgress = useRoomStore((s) => s.voteProgress) const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex) + const hasVoted = useRoomStore((s) => s.hasVoted) const playerId = useRoomStore((s) => s.playerId) const vote = useRoomStore((s) => s.vote) + const voteText = useRoomStore((s) => s.voteText) if (!round) { return ( @@ -24,6 +31,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const question = round.payload as QuizQuestionPayload const truth = reveal ? (reveal.truth as QuizRevealTruth) : null const showReveal = truth !== null + const isFree = question.format === "free" const myResult = reveal ? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""] : undefined @@ -66,21 +74,28 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { {question.prompt} -
- {question.choices.map((choice, index) => ( - - ))} -
+ {isFree ? ( + + ) : ( +
+ {(question.choices ?? []).map((choice, index) => ( + + ))} +
+ )} {!showReveal && - (myChoiceIndex !== null ? ( + (hasVoted ? (

Réponse envoyée — en attente des autres {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} @@ -94,16 +109,49 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { ))} {showReveal && ( -

- {myResult?.correct - ? "Bonne réponse ! 🎉" - : myResult?.choiceIndex == null - ? "Pas de réponse 😴" - : "Raté 💥"} -

+
+ {isFree && ( +

+ Réponse : + {truth?.answer} +

+ )} +

+ {myResult?.correct + ? "Bonne réponse ! 🎉" + : !hasVoted + ? "Pas de réponse 😴" + : "Raté 💥"} +

+
)} ) } + +function FreeAnswer({ + disabled, + onSubmit, +}: { + disabled: boolean + onSubmit: (text: string) => void +}) { + const [text, setText] = useState("") + return ( +
+ setText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)} + /> + +
+ ) +} diff --git a/apps/web/src/components/room-code.tsx b/apps/web/src/components/room-code.tsx index 58142df..ccd599d 100644 --- a/apps/web/src/components/room-code.tsx +++ b/apps/web/src/components/room-code.tsx @@ -1,39 +1,56 @@ import { useState } from "react" -import { Check, Copy } from "lucide-react" +import { Check, Copy, Link2 } from "lucide-react" +import { Button } from "@workspace/ui/components/button" -/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */ +type Copied = "code" | "link" | null + +/** Code de room copiable + bouton pour copier le lien d'invitation. */ export function RoomCode({ code }: { code: string }) { - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useState(null) - async function copy() { + async function copy(what: Copied, text: string) { try { - await navigator.clipboard.writeText(code) - setCopied(true) - setTimeout(() => setCopied(false), 1500) + await navigator.clipboard.writeText(text) + setCopied(what) + setTimeout(() => setCopied(null), 1500) } catch { // presse-papier indisponible (http non sécurisé) : on ignore silencieusement } } + const link = `${window.location.origin}/join/${code}` + return ( - + {copied === "code" ? ( + + ) : ( + + )} + + + + ) } diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx index 7d33241..937123c 100644 --- a/apps/web/src/components/round-transition.tsx +++ b/apps/web/src/components/round-transition.tsx @@ -1,30 +1,74 @@ import { useEffect, useState } from "react" import { createPortal } from "react-dom" 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) -// Animations custom déposées par l'utilisateur (gif/webp/apng/png/mp4...). -// Glisse simplement des fichiers dans src/assets/transitions/ : ils sont -// détectés au build et joués aléatoirement. Sinon, fallback animation Framer. -const CUSTOM = Object.values( - import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", { - eager: true, - import: "default", - }) -) as string[] +// Médias custom déposés par l'utilisateur, rangés par mode : +// src/assets/transitions/quiz/* → transitions de quiz +// src/assets/transitions/blindtest/* → transitions de blindtest +// src/assets/transitions/* → communs (fallback) +// Détectés au build ; sinon, animation Framer par défaut. +const ALL_MEDIA = import.meta.glob( + "../assets/transitions/**/*.{gif,webp,apng,png,avif}", + { eager: true, import: "default" } +) as Record + +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, - * média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie. + * Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand + * il change (quiz ↔ blindtest), sinon affiche un teaser léger de la 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) - // Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre). - const [media] = useState(() => - CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null - ) + const theme = THEME[type] + const [media] = useState(() => { + const pool = mediaFor(type) + return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null + }) useEffect(() => { const t = setTimeout(() => setShow(false), VISIBLE_MS) @@ -36,7 +80,7 @@ export function RoundTransition({ index }: { index: number }) { {show && ( + + {media ? ( ) : ( - <> - - - + {modeChanged ? ( + <> + {theme.emoji} + + {theme.label} + + + ) : ( + <> + + {theme.kind} + + + {index + 1} + + + )} + - - Question - - - {index + 1} - - - PRÊT ?! - - - + PRÊT ?! + + )} )} diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts new file mode 100644 index 0000000..13f47a0 --- /dev/null +++ b/apps/web/src/lib/youtube.ts @@ -0,0 +1,99 @@ +import { useEffect, useMemo, useRef, useState } from "react" + +let apiPromise: Promise | null = null + +/** Charge l'API IFrame YouTube une seule fois. */ +function loadYoutubeApi(): Promise { + if (window.YT?.Player) { + return Promise.resolve() + } + if (apiPromise) { + return apiPromise + } + apiPromise = new Promise((resolve) => { + const previous = window.onYouTubeIframeAPIReady + window.onYouTubeIframeAPIReady = () => { + previous?.() + resolve() + } + const tag = document.createElement("script") + tag.src = "https://www.youtube.com/iframe_api" + document.head.appendChild(tag) + }) + return apiPromise +} + +export interface YoutubeApi { + play: () => void + pause: () => void + seek: (seconds: number) => void + time: () => number + duration: () => number +} + +/** + * Crée un lecteur YouTube pour `youtubeId`. Renvoie un ref à poser sur un div + * hôte, l'état "prêt", et une API impérative (play/pause/seek/time). + */ +export function useYoutube(youtubeId: string) { + const hostRef = useRef(null) + const playerRef = useRef(null) + const [ready, setReady] = useState(false) + + useEffect(() => { + let cancelled = false + const host = hostRef.current + if (!host) { + return + } + setReady(false) + // YT remplace l'élément fourni par une iframe → on lui donne un enfant jetable + // pour que React ne gère jamais ce nœud. + const inner = document.createElement("div") + host.appendChild(inner) + + loadYoutubeApi().then(() => { + if (cancelled || !window.YT) { + return + } + playerRef.current = new window.YT.Player(inner, { + videoId: youtubeId, + playerVars: { + autoplay: 0, + controls: 0, + disablekb: 1, + modestbranding: 1, + rel: 0, + playsinline: 1, + }, + events: { + onReady: () => { + if (!cancelled) { + setReady(true) + } + }, + }, + }) + }) + + return () => { + cancelled = true + playerRef.current?.destroy() + playerRef.current = null + host.replaceChildren() + } + }, [youtubeId]) + + const api = useMemo( + () => ({ + play: () => playerRef.current?.playVideo(), + pause: () => playerRef.current?.pauseVideo(), + seek: (seconds) => playerRef.current?.seekTo(seconds, true), + time: () => playerRef.current?.getCurrentTime() ?? 0, + duration: () => playerRef.current?.getDuration() ?? 0, + }), + [] + ) + + return { hostRef, ready, api } +} diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index c9d305c..c124839 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -1,29 +1,38 @@ import { useState } from "react" import { useLocation } from "wouter" +import { motion } from "framer-motion" +import { LogIn, Sparkles } from "lucide-react" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" const inputClass = "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" +// Emojis geek qui flottent en fond. +const FLOATERS = [ + { emoji: "🎮", x: "8%", y: "18%", delay: 0 }, + { emoji: "🎧", x: "82%", y: "22%", delay: 0.6 }, + { emoji: "🧠", x: "15%", y: "72%", delay: 1.2 }, + { emoji: "👾", x: "78%", y: "70%", delay: 0.3 }, + { emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 }, + { emoji: "🎬", x: "60%", y: "82%", delay: 1.5 }, +] + export function HomePage() { const [, navigate] = useLocation() const createRoom = useRoomStore((s) => s.createRoom) const joinRoom = useRoomStore((s) => s.joinRoom) const connected = useRoomStore((s) => s.connected) - const [name, setName] = useState("") const [code, setCode] = useState("") const [error, setError] = useState(null) const [busy, setBusy] = useState(false) - const canSubmit = name.trim().length > 0 && connected && !busy - async function handleCreate() { setError(null) setBusy(true) try { - const { roomCode } = await createRoom(name.trim()) + const { roomCode } = await createRoom() navigate(`/room/${roomCode}`) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") @@ -36,7 +45,7 @@ export function HomePage() { setError(null) setBusy(true) try { - const { roomCode } = await joinRoom(code.trim().toUpperCase(), name.trim()) + const { roomCode } = await joinRoom(code.trim().toUpperCase()) navigate(`/room/${roomCode}`) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") @@ -46,28 +55,63 @@ export function HomePage() { } return ( -
-
+
+ {/* Halo animé */} + + {/* Emojis flottants */} + {FLOATERS.map((f) => ( + + {f.emoji} + + ))} + +
-

+ NerdWare -

-

- Party game culture geek + +

+ Party game culture geek

- setName(e.target.value)} - /> - - + + +
@@ -77,18 +121,19 @@ export function HomePage() {
setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && code.trim() && handleJoin()} />
@@ -100,7 +145,7 @@ export function HomePage() { {error && (

{error}

)} -
+
) } diff --git a/apps/web/src/pages/join.tsx b/apps/web/src/pages/join.tsx new file mode 100644 index 0000000..bf8d624 --- /dev/null +++ b/apps/web/src/pages/join.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from "react" +import { Link, useLocation } from "wouter" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" + +/** Lien d'invitation /join/:code → rejoint la room puis redirige vers le lobby. */ +export function JoinPage({ code }: { code: string }) { + const [, navigate] = useLocation() + const joinRoom = useRoomStore((s) => s.joinRoom) + const connected = useRoomStore((s) => s.connected) + const [error, setError] = useState(null) + const tried = useRef(false) + + useEffect(() => { + if (!connected || tried.current) { + return + } + tried.current = true + joinRoom(code) + .then(({ roomCode }) => navigate(`/room/${roomCode}`, { replace: true })) + .catch((err) => + setError((err as { message?: string }).message ?? "Room introuvable.") + ) + }, [connected, code, joinRoom, navigate]) + + return ( +
+ {error ? ( + <> +

{error}

+ + + + + ) : ( +

+ Connexion à la room {code}… +

+ )} +
+ ) +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 79e8212..5c00307 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -3,22 +3,28 @@ import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { LobbyView } from "@/components/lobby-view" import { QuizView } from "@/components/quiz-view" +import { BlindtestView } from "@/components/blindtest-view" import { GameEndView } from "@/components/game-end-view" import { PlayerCards } from "@/components/player-cards" import { RoomCode } from "@/components/room-code" import { Boom } from "@/components/boom" import { RoundTransition } from "@/components/round-transition" +import { PseudoScreen } from "@/components/pseudo-screen" export function RoomPage({ code }: { code: string }) { const snapshot = useRoomStore((s) => s.snapshot) + const roomCode = useRoomStore((s) => s.roomCode) const playerId = useRoomStore((s) => s.playerId) const connected = useRoomStore((s) => s.connected) + const pseudoSet = useRoomStore((s) => s.pseudoSet) const boomKey = useRoomStore((s) => s.boomKey) const roundKey = useRoomStore((s) => s.roundKey) const voteProgress = useRoomStore((s) => s.voteProgress) + 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. - if (!snapshot || snapshot.code !== code) { + // Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil. + if (roomCode !== code) { return (

@@ -31,6 +37,20 @@ export function RoomPage({ code }: { code: string }) { ) } + // Pseudo pas encore choisi : on l'affiche d'abord. + if (!pseudoSet) { + return + } + + // En attente du premier snapshot. + if (!snapshot || snapshot.code !== code) { + return ( +

+

Chargement…

+
+ ) + } + // Cards de scores visibles en permanence pendant le jeu (suivi continu). const inGame = snapshot.status === "in_round" || @@ -58,13 +78,23 @@ export function RoomPage({ code }: { code: string }) { )} {snapshot.status === "lobby" && } - {inGame && } + {inGame && + (round?.type === "blindtest" ? ( + + ) : ( + + ))} {snapshot.status === "ended" && }
{boomKey > 0 && } - {roundKey > 0 && ( - + {roundKey > 0 && round && ( + )}
) diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index bf8d558..b134866 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -1,11 +1,19 @@ import { create } from "zustand" -import type { - ErrorPayload, - PlayerScore, - RoomCreatedPayload, - RoomSnapshot, - RoundStartPayload, - VoteAckPayload, +import { + DEFAULT_ROOM_SETTINGS, + type BlindtestAnswer, + type BlindtestTrackInfo, + type ErrorPayload, + type MediaControlPayload, + type MediaSyncPayload, + type PlayerScore, + type RoomCreatedPayload, + type RoomSnapshot, + type RoundConfig, + type RoundStartPayload, + type SubmitOkPayload, + type UpdateSettingsPayload, + type VoteAckPayload, } from "@nerdware/shared" import { socket } from "@/lib/socket" @@ -13,6 +21,8 @@ import { socket } from "@/lib/socket" export interface ActiveRound { type: RoundStartPayload["type"] djId?: string + /** Blindtest : true si c'est MON titre (je dois faire deviner, pas voter). */ + mine?: boolean startsAt: number endsAt: number payload: unknown @@ -26,9 +36,10 @@ export interface RoundReveal { interface RoomState { connected: boolean - /** Identité locale, pour reconnaître "moi" dans le snapshot. */ playerId: string | null roomCode: string | null + /** Le joueur local a-t-il choisi son pseudo dans la room ? */ + pseudoSet: boolean snapshot: RoomSnapshot | null lastError: ErrorPayload | null @@ -37,16 +48,27 @@ interface RoomState { voteProgress: VoteAckPayload | null reveal: RoundReveal | null myChoiceIndex: number | null + hasVoted: boolean finalScores: PlayerScore[] | null - /** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */ + gameTracks: BlindtestTrackInfo[] | null + mediaSync: MediaSyncPayload | null boomKey: number - /** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */ roundKey: number + /** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */ + roundModeChanged: boolean - createRoom: (playerName: string) => Promise - joinRoom: (roomCode: string, playerName: string) => Promise - startGame: (questionCount: number) => Promise + createRoom: () => Promise + joinRoom: (roomCode: string) => Promise + setName: (name: string) => void + updateSettings: (partial: Partial) => void + startGame: (rounds: RoundConfig[]) => Promise + submitTrack: (youtubeUrl: string) => Promise + removeTrack: (trackId: string) => Promise + returnToLobby: () => void vote: (choiceIndex: number) => void + voteText: (text: string) => void + voteBlindtest: (answer: BlindtestAnswer) => void + mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void boom: () => void clearError: () => void reset: () => void @@ -56,21 +78,30 @@ export const useRoomStore = create((set, get) => ({ connected: socket.connected, playerId: null, roomCode: null, + pseudoSet: false, snapshot: null, lastError: null, round: null, voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, finalScores: null, + gameTracks: null, + mediaSync: null, boomKey: 0, roundKey: 0, + roundModeChanged: false, - createRoom: (playerName) => + createRoom: () => new Promise((resolve, reject) => { - socket.emit("room:create", { playerName }, (res) => { + socket.emit("room:create", {}, (res) => { if (res.ok) { - set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + set({ + playerId: res.data.playerId, + roomCode: res.data.roomCode, + pseudoSet: false, + }) resolve(res.data) } else { set({ lastError: res.error }) @@ -79,11 +110,15 @@ export const useRoomStore = create((set, get) => ({ }) }), - joinRoom: (roomCode, playerName) => + joinRoom: (roomCode) => new Promise((resolve, reject) => { - socket.emit("room:join", { roomCode, playerName }, (res) => { + socket.emit("room:join", { roomCode }, (res) => { if (res.ok) { - set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + set({ + playerId: res.data.playerId, + roomCode: res.data.roomCode, + pseudoSet: false, + }) resolve(res.data) } else { set({ lastError: res.error }) @@ -92,17 +127,28 @@ export const useRoomStore = create((set, get) => ({ }) }), - startGame: (questionCount) => + setName: (name) => { + if (name.trim().length === 0) { + return + } + socket.emit("player:setName", { name: name.trim() }) + set({ pseudoSet: true }) + }, + + updateSettings: (partial) => { + const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS + socket.emit("lobby:updateSettings", { + gameType: partial.gameType ?? current.gameType, + blindtestMode: partial.blindtestMode ?? current.blindtestMode, + roundDuration: partial.roundDuration ?? current.roundDuration, + tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer, + rounds: partial.rounds ?? current.rounds, + }) + }, + + startGame: (rounds) => new Promise((resolve, reject) => { - const rounds = Array.from({ length: questionCount }, () => ({ - type: "quiz" as const, - })) - // On pousse la séquence de manches, puis on lance. - socket.emit("lobby:updateSettings", { - blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist", - roundDuration: 20, - rounds, - }) + get().updateSettings({ rounds }) socket.emit("game:start", (res) => { if (res.ok) { resolve() @@ -113,36 +159,91 @@ export const useRoomStore = create((set, get) => ({ }) }), + submitTrack: (youtubeUrl) => + new Promise((resolve) => { + socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res)) + }), + + removeTrack: (trackId) => + new Promise((resolve) => { + socket.emit("blindtest:removeTrack", { trackId }, (res) => resolve(res.ok)) + }), + + returnToLobby: () => { + socket.emit("lobby:return") + }, + vote: (choiceIndex) => { - if (get().myChoiceIndex !== null) { - return // vote déjà émis pour cette manche + if (get().hasVoted) { + return } - set({ myChoiceIndex: choiceIndex }) + set({ myChoiceIndex: choiceIndex, hasVoted: true }) 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) => { + if (get().hasVoted) { + return + } + set({ hasVoted: true }) + socket.emit("round:vote", { answer }) + }, + + mediaControl: (action, positionSec) => { + socket.emit("media:control", { action, positionSec }) + }, + boom: () => set((s) => ({ boomKey: s.boomKey + 1 })), clearError: () => set({ lastError: null }), reset: () => set({ roomCode: null, + pseudoSet: false, snapshot: null, lastError: null, round: null, voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, finalScores: null, + gameTracks: null, + mediaSync: null, boomKey: 0, roundKey: 0, + roundModeChanged: false, }), })) // Listeners socket → on pousse l'état serveur dans le store (source de vérité). socket.on("connect", () => useRoomStore.setState({ connected: true })) socket.on("disconnect", () => useRoomStore.setState({ connected: false })) -socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot })) +socket.on("room:state", (snapshot) => + useRoomStore.setState( + snapshot.status === "lobby" + ? { + snapshot, + round: null, + reveal: null, + voteProgress: null, + myChoiceIndex: null, + hasVoted: false, + finalScores: null, + gameTracks: null, + mediaSync: null, + } + : { snapshot } + ) +) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("round:start", (payload) => @@ -150,6 +251,7 @@ socket.on("round:start", (payload) => round: { type: payload.type, djId: payload.djId, + mine: payload.mine, startsAt: payload.startsAt, endsAt: payload.endsAt, payload: payload.payload, @@ -157,13 +259,17 @@ socket.on("round:start", (payload) => voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, + mediaSync: null, roundKey: s.roundKey + 1, + roundModeChanged: (s.round?.type ?? null) !== payload.type, })) ) socket.on("round:voteAck", (voteProgress) => useRoomStore.setState({ voteProgress }) ) socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal })) -socket.on("game:end", ({ finalScores }) => - useRoomStore.setState({ finalScores }) +socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync })) +socket.on("game:end", ({ finalScores, tracks }) => + useRoomStore.setState({ finalScores, gameTracks: tracks ?? null }) ) diff --git a/apps/web/src/types/youtube.d.ts b/apps/web/src/types/youtube.d.ts new file mode 100644 index 0000000..bc6f8a5 --- /dev/null +++ b/apps/web/src/types/youtube.d.ts @@ -0,0 +1,44 @@ +// Déclarations minimales de l'API YouTube IFrame Player (ce qu'on utilise). + +export {} + +declare global { + namespace YT { + interface PlayerEvent { + target: Player + data: number + } + interface PlayerOptions { + videoId?: string + playerVars?: Record + events?: { + onReady?: (e: PlayerEvent) => void + onStateChange?: (e: PlayerEvent) => void + } + } + class Player { + constructor(el: HTMLElement | string, opts: PlayerOptions) + playVideo(): void + pauseVideo(): void + seekTo(seconds: number, allowSeekAhead: boolean): void + getCurrentTime(): number + getDuration(): number + getPlayerState(): number + loadVideoById(id: string): void + destroy(): void + } + const PlayerState: { + UNSTARTED: -1 + ENDED: 0 + PLAYING: 1 + PAUSED: 2 + BUFFERING: 3 + CUED: 5 + } + } + + interface Window { + YT?: typeof YT + onYouTubeIframeAPIReady?: () => void + } +} diff --git a/packages/shared/src/blindtest.ts b/packages/shared/src/blindtest.ts new file mode 100644 index 0000000..5382d15 --- /dev/null +++ b/packages/shared/src/blindtest.ts @@ -0,0 +1,37 @@ +// Types concrets de l'épreuve Blindtest, partagés client/serveur. +// L'audio est joué localement par chaque client mais piloté par le DJ (media:sync). + +/** Payload diffusé au lancement d'une manche blindtest (round:start), SANS la réponse. */ +export interface BlindtestRoundPayload { + trackId: string + youtubeId: string +} + +/** Vérité révélée à tous (round:reveal → truth), inclut qui a soumis le titre. */ +export interface BlindtestRevealTruth { + title: string + artist: string + youtubeId: string + submittedBy: string + submittedByName: string +} + +/** Résultat d'un joueur sur la manche (détail selon le mode). */ +export interface BlindtestPlayerResult { + titleCorrect?: boolean + artistCorrect?: boolean + guessedCorrect?: boolean + points: number +} + +/** round:reveal → perPlayerResult : playerId → résultat. */ +export type BlindtestPerPlayerResult = Record + +/** Titre récapitulé en fin de partie (export / récupération des liens). */ +export interface BlindtestTrackInfo { + title: string + artist: string + youtubeId: string + url: string + submittedByName: string +} diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index fcfa8e0..d7ee165 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -7,6 +7,9 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended" /** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */ 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. */ export type BlindtestMode = "title_artist" | "who_added" | "mixed" @@ -27,17 +30,23 @@ export interface RoundConfig { /** Réglages de la room, modifiables dans le lobby par l'hôte. */ export interface RoomSettings { + /** Type de partie choisi dans le lobby (pilote l'UI partagée). */ + gameType: GameType blindtestMode: BlindtestMode /** Durée d'une manche en secondes (def. 60). */ roundDuration: number + /** Nombre de titres que chaque joueur soumet (blindtest). */ + tracksPerPlayer: number /** Séquence d'épreuves de la partie. */ rounds: RoundConfig[] } /** Réglages par défaut d'une nouvelle room. */ export const DEFAULT_ROOM_SETTINGS: RoomSettings = { + gameType: "mixed", blindtestMode: "title_artist", roundDuration: 60, + tracksPerPlayer: 2, rounds: [], } @@ -86,4 +95,6 @@ export interface RoomSnapshot { currentRound: number /** Nombre total de manches planifiées. */ totalRounds: number + /** Blindtest : nombre de titres soumis par joueur (comptes seulement, jamais les titres). */ + submissions: { playerId: string; count: number }[] } diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index d18b5b2..2c1f023 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -4,16 +4,18 @@ import type { Answer, BlindtestMode, + GameType, PlayerScore, RoomSnapshot, RoundConfig, RoundType, } from "./domain" +import type { BlindtestTrackInfo } from "./blindtest" // --- Payloads ------------------------------------------------------------ export interface RoomCreatePayload { - playerName: string + playerName?: string } export interface RoomCreatedPayload { @@ -23,12 +25,18 @@ export interface RoomCreatedPayload { export interface RoomJoinPayload { roomCode: string - playerName: string + playerName?: string +} + +export interface SetNamePayload { + name: string } export interface UpdateSettingsPayload { + gameType: GameType blindtestMode: BlindtestMode roundDuration: number + tracksPerPlayer: number rounds: RoundConfig[] } @@ -43,6 +51,17 @@ export interface SubmitTrackPayload { export interface SubmitOkPayload { accepted: boolean reason?: string + /** Id du titre créé (pour pouvoir le supprimer). */ + trackId?: string + youtubeId?: string + /** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */ + title?: string + /** Nombre total de titres soumis par ce joueur après cet ajout. */ + count?: number +} + +export interface RemoveTrackPayload { + trackId: string } export interface RoundStartPayload { @@ -52,6 +71,8 @@ export interface RoundStartPayload { startsAt: number /** Timestamp serveur de fin de manche (startsAt + roundDuration). */ endsAt: number + /** Envoyé uniquement au contributeur du titre (blindtest) : "c'est le tien". */ + mine?: boolean /** Payload spécifique au type d'épreuve, SANS la réponse. */ payload: unknown } @@ -93,6 +114,8 @@ export interface ScoreUpdatePayload { export interface GameEndPayload { finalScores: PlayerScore[] + /** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */ + tracks?: BlindtestTrackInfo[] spotifyExport?: unknown } @@ -110,9 +133,18 @@ export type Ack = (result: { ok: true; data: T } | { ok: false; error: ErrorP export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void + "player:setName": (payload: SetNamePayload) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void + "lobby:return": () => void "game:start": (ack: Ack) => void - "blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack) => void + "blindtest:submitTrack": ( + payload: SubmitTrackPayload, + ack: (res: SubmitOkPayload) => void + ) => void + "blindtest:removeTrack": ( + payload: RemoveTrackPayload, + ack: (res: { ok: boolean }) => void + ) => void "media:control": (payload: MediaControlPayload) => void "round:vote": (payload: VotePayload) => void } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dece734..27085c7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from "./domain" export * from "./events" export * from "./quiz" +export * from "./blindtest" diff --git a/packages/shared/src/quiz.ts b/packages/shared/src/quiz.ts index a2156a6..d61bd3c 100644 --- a/packages/shared/src/quiz.ts +++ b/packages/shared/src/quiz.ts @@ -8,8 +8,8 @@ import type { QuizFormat } from "./domain" export interface QuizQuestionPayload { format: QuizFormat prompt: string - /** Choix proposés (truefalse = ["Vrai", "Faux"]). */ - choices: string[] + /** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */ + choices?: string[] category?: string /** 1 (facile) .. 3 (difficile). */ difficulty?: number @@ -17,7 +17,10 @@ export interface QuizQuestionPayload { /** Vérité révélée à tous (round:reveal → truth). */ 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. */