From 98e06b8206d3a43691a15331f1bee2a8bfc4ec50 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 13:02:28 +0200 Subject: [PATCH] feat(blindtest): YouTube blindtest mode with neutral DJ + synced playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - BlindtestRound (GameRound): neutral DJ pick (never the contributor), per-mode scoring (title_artist fuzzy / who_added / mixed), reveal exposes title/artist/submittedBy; contributor doesn't score on own track - track submission: blindtest:submitTrack validates via YouTube oEmbed (exists + embeddable) and captures title/artist; per-player quota; dedup - DJ media relay: media:control (DJ only) → media:sync broadcast (engine) - per-room shuffled track pool; submissions count in room snapshot - shared: blindtest payloads, gameType/tracksPerPlayer in settings - tests: DJ neutrality, scoring per mode, fuzzy match, youtube id extraction Client: - YouTube IFrame player (hidden behind a spinning disc — audio only, no title leak), DJ transport (play/pause/restart), non-DJ follows media:sync with latency compensation - lobby: game-type switch (Quiz/Blindtest), blindtest config + per-player track submission UI - vote forms per mode; reveal card with title/artist/who-added Roadmap V1 step 6 (highest technical risk). Server flow verified end-to-end over the wire (submit→start→DJ relay→vote→reveal→score). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 23 +- .../modes/blindtest/blindtest-round.test.ts | 110 ++++++++ .../game/modes/blindtest/blindtest-round.ts | 125 +++++++++ apps/server/src/game/modes/blindtest/index.ts | 6 + apps/server/src/game/modes/blindtest/match.ts | 54 ++++ apps/server/src/game/modes/blindtest/pool.ts | 26 ++ .../src/game/modes/blindtest/youtube.test.ts | 20 ++ .../src/game/modes/blindtest/youtube.ts | 53 ++++ apps/server/src/game/modes/index.ts | 10 +- apps/server/src/game/round.ts | 10 +- apps/server/src/handlers.ts | 64 +++++ apps/server/src/rooms.ts | 17 ++ apps/web/src/components/blindtest-view.tsx | 264 ++++++++++++++++++ apps/web/src/components/lobby-view.tsx | 221 +++++++++++++-- apps/web/src/lib/youtube.ts | 97 +++++++ apps/web/src/pages/room.tsx | 9 +- apps/web/src/store/room.ts | 85 ++++-- apps/web/src/types/youtube.d.ts | 44 +++ packages/shared/src/blindtest.ts | 28 ++ packages/shared/src/domain.ts | 8 + packages/shared/src/events.ts | 11 +- packages/shared/src/index.ts | 1 + 22 files changed, 1238 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/game/modes/blindtest/blindtest-round.test.ts create mode 100644 apps/server/src/game/modes/blindtest/blindtest-round.ts create mode 100644 apps/server/src/game/modes/blindtest/index.ts create mode 100644 apps/server/src/game/modes/blindtest/match.ts create mode 100644 apps/server/src/game/modes/blindtest/pool.ts create mode 100644 apps/server/src/game/modes/blindtest/youtube.test.ts create mode 100644 apps/server/src/game/modes/blindtest/youtube.ts create mode 100644 apps/web/src/components/blindtest-view.tsx create mode 100644 apps/web/src/lib/youtube.ts create mode 100644 apps/web/src/types/youtube.d.ts create mode 100644 packages/shared/src/blindtest.ts diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 3bef317..7df50c9 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" @@ -87,6 +92,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) 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..bcfb726 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -0,0 +1,110 @@ +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 tire un DJ 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) // jamais Alice (contributrice) + expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" }) + }) + + 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..d7dbbe1 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -0,0 +1,125 @@ +// Épreuve Blindtest : un titre = une manche. Un DJ neutre (jamais le +// contributeur) pilote la lecture ; tout le monde vote à l'aveugle. + +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 + +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 +} + +/** Calcule le résultat d'un vote selon le mode. */ +function resultFor( + 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) + const djId = track ? pickDj(room, track.submittedBy) : null + const payload: BlindtestRoundPayload | null = track + ? { trackId: track.id, youtubeId: track.youtubeId } + : null + return { djId, payload, data: track ? { track } : null } + } + + submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { + // Premier vote verrouillé (idempotent). + if (ctx.votes.has(playerId)) { + return + } + ctx.votes.set(playerId, answer) + } + + reveal(ctx: RoundContext): { + truth: BlindtestRevealTruth + perPlayerResult: BlindtestPerPlayerResult + } { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const perPlayerResult: BlindtestPerPlayerResult = {} + for (const player of ctx.room.players.values()) { + perPlayerResult[player.id] = resultFor(mode, ctx.votes.get(player.id), track) + } + 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, + } + } + + score(ctx: RoundContext): ScoreDelta[] { + const { track } = ctx.data as BlindtestRoundData + const mode = ctx.room.settings.blindtestMode + const deltas: ScoreDelta[] = [] + for (const [playerId, answer] of ctx.votes) { + // Le contributeur ne marque pas sur son propre titre. + if (playerId === track.submittedBy) { + continue + } + const { points } = resultFor(mode, answer, track) + if (points > 0) { + deltas.push({ playerId, delta: 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/match.ts b/apps/server/src/game/modes/blindtest/match.ts new file mode 100644 index 0000000..235cd99 --- /dev/null +++ b/apps/server/src/game/modes/blindtest/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/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/round.ts b/apps/server/src/game/round.ts index 8f3ca62..7bfc21a 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. */ @@ -52,4 +58,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..0fd0e96 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, @@ -91,8 +96,10 @@ export function registerRoomHandlers( return } room.settings = { + gameType: payload.gameType, blindtestMode: payload.blindtestMode, roundDuration: payload.roundDuration, + tracksPerPlayer: payload.tracksPerPlayer, rounds: payload.rounds, } broadcastState(room) @@ -133,6 +140,63 @@ 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 + } + room.blindtestTracks.push({ + id: randomUUID(), + youtubeId, + url: payload.youtubeUrl, + title: meta.title, + artist: meta.artist, + submittedBy: playerId, + }) + const count = mine.length + 1 + ack({ accepted: true, title: meta.title, count }) + 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 diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index fadc8f7..bffbd5c 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) @@ -115,6 +128,10 @@ export class RoomManager { })), currentRound: room.currentRound, totalRounds: room.settings.rounds.length, + submissions: [...room.players.values()].map((p) => ({ + playerId: p.id, + count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length, + })), } } diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx new file mode 100644 index 0000000..e6b9596 --- /dev/null +++ b/apps/web/src/components/blindtest-view.tsx @@ -0,0 +1,264 @@ +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 } 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" + +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]) + + function djPlay() { + api.play() + mediaControl("play", api.time()) + } + function djPause() { + api.pause() + mediaControl("pause", api.time()) + } + function djRestart() { + api.seek(0) + mediaControl("seek", 0) + } + + return ( +
+
+ + Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds} + + {!showReveal && round && ( + + )} +
+ + {/* Pochette : le lecteur YouTube est caché derrière (son seulement). */} +
+
+ {!showReveal && ( +
+ + + +
+ )} +
+ + {isDj && !showReveal && ( +
+ + Tu es le DJ 🎧 + +
+ + + +
+
+ )} + + {!showReveal && ( + + )} + + {showReveal && truth && ( + + )} +
+ ) +} + +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.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/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index b315a3f..4713f3d 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,25 +1,38 @@ import { useState } from "react" -import type { RoomSnapshot } from "@nerdware/shared" +import type { BlindtestMode, RoomSnapshot, RoundConfig } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" const QUESTION_OPTIONS = [3, 5, 10] +const TRACKS_OPTIONS = [1, 2, 3] +const 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" 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) + + async function start(rounds: RoundConfig[]) { setError(null) setBusy(true) try { - await startGame(count) + await startGame(rounds) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") } finally { @@ -57,35 +70,207 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { - {isHost ? ( -
-
- Questions de quiz + {/* Choix du type de partie (hôte). */} + {isHost && ( +
+ {(["quiz", "blindtest"] as const).map((type) => ( + + ))} +
+ )} + + {gameType === "quiz" ? ( + isHost ? ( +
+
+ Questions de quiz +
+ {QUESTION_OPTIONS.map((n) => ( + + ))} +
+
+ +
+ ) : ( +

+ En attente du lancement par l'hôte… +

+ ) + ) : ( + s.playerId === playerId)?.count ?? 0 + } + totalTracks={totalTracks} + busy={busy} + onConfig={updateSettings} + onStart={() => + start( + Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) + ) + } + /> + )} + + {error &&

{error}

} +
+ ) +} + +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(null) + const [accepted, setAccepted] = 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) { + setAccepted((a) => [...a, res.title ?? url.trim()]) + setUrl("") + setFeedback(null) + } else { + setFeedback(res.reason ?? "Refusé") + } + setSubmitting(false) + } + + return ( +
+ {isHost && ( +
+
+ Mode
- {QUESTION_OPTIONS.map((n) => ( + {(["title_artist", "who_added", "mixed"] as const).map((m) => ( + + ))} +
+
+
+ Titres par joueur +
+ {TRACKS_OPTIONS.map((n) => ( ))}
-
+ )} + +
+ + Tes titres ({myCount}/{tracksPerPlayer}) + +
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + - {error && ( -

{error}

- )} -
+
+ {feedback &&

{feedback}

} + {accepted.length > 0 && ( +
    + {accepted.map((t, i) => ( +
  • + ✓ {t} +
  • + ))} +
+ )} + + + {isHost ? ( + ) : (

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

)} - + ) } diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts new file mode 100644 index 0000000..b4308c4 --- /dev/null +++ b/apps/web/src/lib/youtube.ts @@ -0,0 +1,97 @@ +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 +} + +/** + * 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, + }), + [] + ) + + return { hostRef, ready, api } +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 79e8212..52e7957 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -3,6 +3,7 @@ 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" @@ -16,6 +17,7 @@ export function RoomPage({ code }: { code: string }) { const boomKey = useRoomStore((s) => s.boomKey) const roundKey = useRoomStore((s) => s.roundKey) const voteProgress = useRoomStore((s) => s.voteProgress) + const round = useRoomStore((s) => s.round) // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. if (!snapshot || snapshot.code !== code) { @@ -58,7 +60,12 @@ export function RoomPage({ code }: { code: string }) { )} {snapshot.status === "lobby" && } - {inGame && } + {inGame && + (round?.type === "blindtest" ? ( + + ) : ( + + ))} {snapshot.status === "ended" && } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index bf8d558..7a30cea 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -1,11 +1,18 @@ import { create } from "zustand" -import type { - ErrorPayload, - PlayerScore, - RoomCreatedPayload, - RoomSnapshot, - RoundStartPayload, - VoteAckPayload, +import { + DEFAULT_ROOM_SETTINGS, + type BlindtestAnswer, + 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" @@ -26,7 +33,6 @@ export interface RoundReveal { interface RoomState { connected: boolean - /** Identité locale, pour reconnaître "moi" dans le snapshot. */ playerId: string | null roomCode: string | null snapshot: RoomSnapshot | null @@ -37,16 +43,20 @@ 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). */ + mediaSync: MediaSyncPayload | null boomKey: number - /** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */ roundKey: number createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise - startGame: (questionCount: number) => Promise + updateSettings: (partial: Partial) => void + startGame: (rounds: RoundConfig[]) => Promise + submitTrack: (youtubeUrl: string) => Promise vote: (choiceIndex: number) => void + voteBlindtest: (answer: BlindtestAnswer) => void + mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void boom: () => void clearError: () => void reset: () => void @@ -62,7 +72,9 @@ export const useRoomStore = create((set, get) => ({ voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, finalScores: null, + mediaSync: null, boomKey: 0, roundKey: 0, @@ -92,17 +104,20 @@ export const useRoomStore = create((set, get) => ({ }) }), - startGame: (questionCount) => + 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,14 +128,31 @@ export const useRoomStore = create((set, get) => ({ }) }), + submitTrack: (youtubeUrl) => + new Promise((resolve) => { + socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res)) + }), + 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 } }) }, + 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 }), @@ -133,7 +165,9 @@ export const useRoomStore = create((set, get) => ({ voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, finalScores: null, + mediaSync: null, boomKey: 0, roundKey: 0, }), @@ -157,6 +191,8 @@ socket.on("round:start", (payload) => voteProgress: null, reveal: null, myChoiceIndex: null, + hasVoted: false, + mediaSync: null, roundKey: s.roundKey + 1, })) ) @@ -164,6 +200,7 @@ socket.on("round:voteAck", (voteProgress) => useRoomStore.setState({ voteProgress }) ) socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal })) +socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync })) socket.on("game:end", ({ finalScores }) => useRoomStore.setState({ finalScores }) ) 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..9c37acf --- /dev/null +++ b/packages/shared/src/blindtest.ts @@ -0,0 +1,28 @@ +// 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 diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index fcfa8e0..a56a6f6 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -27,17 +27,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: RoundType 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: "quiz", blindtestMode: "title_artist", roundDuration: 60, + tracksPerPlayer: 2, rounds: [], } @@ -86,4 +92,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..e03de62 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -27,8 +27,10 @@ export interface RoomJoinPayload { } export interface UpdateSettingsPayload { + gameType: RoundType blindtestMode: BlindtestMode roundDuration: number + tracksPerPlayer: number rounds: RoundConfig[] } @@ -43,6 +45,10 @@ export interface SubmitTrackPayload { export interface SubmitOkPayload { accepted: boolean reason?: 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 RoundStartPayload { @@ -112,7 +118,10 @@ export interface ClientToServerEvents { "room:join": (payload: RoomJoinPayload, ack: Ack) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void "game:start": (ack: Ack) => void - "blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack) => void + "blindtest:submitTrack": ( + payload: SubmitTrackPayload, + ack: (res: SubmitOkPayload) => 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"