From 98e06b8206d3a43691a15331f1bee2a8bfc4ec50 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 13:02:28 +0200 Subject: [PATCH 1/9] 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" From e3315d3b3507030e76c02ad756daadd1677d2945 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 13:41:29 +0200 Subject: [PATCH 2/9] feat: mixed games by default, free-text quiz, mode-aware transitions Mixed mode (default): - gameType is now mixed | quiz | blindtest, default "mixed"; the lobby interleaves quiz + blindtest rounds so modes alternate - lobby reworked: 3-way game-type switch, quiz count and/or blindtest config + track submission shown per selected type Free-text quiz questions: - quiz supports the `free` format (e.g. maths) alongside mcq/truefalse: payload without choices, {text} vote, tolerant matching on acceptedAnswers - shared QuizQuestionPayload.choices optional; reveal truth carries answer - match util moved to game/match.ts (shared by quiz + blindtest) - repo/seed include free + acceptedAnswers; in-code bank gains free questions - client quiz view renders a text input for free questions Mode-aware transitions: - RoundTransition themed per mode (colors/label/emoji); announces the mode on change (quiz <-> blindtest), lighter teaser within the same mode - custom media buckets per mode: assets/transitions/{quiz,blindtest}/ + shared - store tracks roundModeChanged Verified e2e: mixed game alternates quiz/blindtest to completion; free-text scoring covered by tests (22 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/db/quiz-repo.ts | 17 +- apps/server/src/db/seed.ts | 1 + .../src/game/{modes/blindtest => }/match.ts | 0 .../modes/blindtest/blindtest-round.test.ts | 2 +- .../game/modes/blindtest/blindtest-round.ts | 2 +- apps/server/src/game/modes/quiz/questions.ts | 49 ++- .../src/game/modes/quiz/quiz-round.test.ts | 30 +- apps/server/src/game/modes/quiz/quiz-round.ts | 73 +++-- apps/web/src/assets/transitions/README.md | 16 +- apps/web/src/components/lobby-view.tsx | 304 +++++++++--------- apps/web/src/components/quiz-view.tsx | 92 ++++-- apps/web/src/components/round-transition.tsx | 183 +++++++---- apps/web/src/pages/room.tsx | 10 +- apps/web/src/store/room.ts | 14 + packages/shared/src/domain.ts | 7 +- packages/shared/src/events.ts | 3 +- packages/shared/src/quiz.ts | 9 +- 17 files changed, 529 insertions(+), 283 deletions(-) rename apps/server/src/game/{modes/blindtest => }/match.ts (100%) 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/modes/blindtest/match.ts b/apps/server/src/game/match.ts similarity index 100% rename from apps/server/src/game/modes/blindtest/match.ts rename to apps/server/src/game/match.ts diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts index bcfb726..30a8eb1 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -3,7 +3,7 @@ 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" +import { fuzzyMatch, normalize } from "../../match" function track(submittedBy: string): BlindtestTrack { return { diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index d7dbbe1..59199cf 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -12,7 +12,7 @@ import type { } from "@nerdware/shared" import type { GameRound, RoundContext, RoundStart } from "../../round" import type { BlindtestTrack, ServerRoom } from "../../../rooms" -import { fuzzyMatch } from "./match" +import { fuzzyMatch } from "../../match" import { takeTrack } from "./pool" const TITLE_POINTS = 60 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/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/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 4713f3d..8eb8375 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,11 +1,21 @@ import { useState } from "react" -import type { BlindtestMode, RoomSnapshot, RoundConfig } from "@nerdware/shared" +import type { + BlindtestMode, + GameType, + RoomSnapshot, + RoundConfig, +} from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" const QUESTION_OPTIONS = [3, 5, 10] const TRACKS_OPTIONS = [1, 2, 3] +const GAME_TYPES: { value: GameType; label: string }[] = [ + { value: "mixed", label: "Mixte" }, + { value: "quiz", label: "Quiz" }, + { value: "blindtest", label: "Blindtest" }, +] const MODE_LABELS: Record = { title_artist: "Titre & artiste", who_added: "Qui l'a ajouté ?", @@ -15,6 +25,35 @@ const MODE_LABELS: Record = { const inputClass = "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" +/** Construit la séquence de manches selon le type de partie. */ +function buildRounds( + gameType: GameType, + quizCount: number, + totalTracks: number +): RoundConfig[] { + if (gameType === "quiz") { + return Array.from({ length: quizCount }, () => ({ type: "quiz" })) + } + if (gameType === "blindtest") { + return Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) + } + // Mixte : on alterne quiz / blindtest pour multiplier les changements de mode. + const rounds: RoundConfig[] = [] + let q = quizCount + let b = totalTracks + while (q > 0 || b > 0) { + if (q > 0) { + rounds.push({ type: "quiz" }) + q-- + } + if (b > 0) { + rounds.push({ type: "blindtest" }) + b-- + } + } + return rounds +} + export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const updateSettings = useRoomStore((s) => s.updateSettings) @@ -27,8 +66,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const [error, setError] = useState(null) const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) + const myCount = + snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0 + const showQuiz = gameType === "quiz" || gameType === "mixed" + const showBlindtest = gameType === "blindtest" || gameType === "mixed" - async function start(rounds: RoundConfig[]) { + const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks) + const canStart = rounds.length > 0 + + async function start() { setError(null) setBusy(true) try { @@ -70,141 +116,51 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { - {/* Choix du type de partie (hôte). */} {isHost && (
- {(["quiz", "blindtest"] as const).map((type) => ( + {GAME_TYPES.map((t) => ( ))}
)} - {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" })) - ) - } - /> + {isHost && showQuiz && ( +
+ Questions de quiz +
+ {QUESTION_OPTIONS.map((n) => ( + + ))} +
+
)} - {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 && ( + {isHost && showBlindtest && (
- Mode + Mode blindtest
{(["title_artist", "who_added", "mixed"] as const).map((m) => ( @@ -219,7 +175,7 @@ function BlindtestLobby({ key={n} size="sm" variant={tracksPerPlayer === n ? "default" : "secondary"} - onClick={() => onConfig({ tracksPerPlayer: n })} + onClick={() => updateSettings({ tracksPerPlayer: n })} > {n} @@ -229,48 +185,88 @@ function BlindtestLobby({
)} -
- - Tes titres ({myCount}/{tracksPerPlayer}) - -
- setUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - /> - -
- {feedback &&

{feedback}

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

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

)} -
+ + {error &&

{error}

} + + ) +} + +function TrackSubmission({ + tracksPerPlayer, + myCount, +}: { + tracksPerPlayer: number + myCount: number +}) { + const submitTrack = useRoomStore((s) => s.submitTrack) + const [url, setUrl] = useState("") + const [submitting, setSubmitting] = useState(false) + const [feedback, setFeedback] = useState(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("") + } else { + setFeedback(res.reason ?? "Refusé") + } + setSubmitting(false) + } + + return ( +
+ + Tes titres ({myCount}/{tracksPerPlayer}) + +
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + +
+ {feedback &&

{feedback}

} + {accepted.length > 0 && ( +
    + {accepted.map((t, i) => ( +
  • + ✓ {t} +
  • + ))} +
+ )} +
) } 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/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/pages/room.tsx b/apps/web/src/pages/room.tsx index 52e7957..ee937fc 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -18,6 +18,7 @@ export function RoomPage({ code }: { code: string }) { 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) { @@ -70,8 +71,13 @@ export function RoomPage({ code }: { code: string }) { {boomKey > 0 && } - {roundKey > 0 && ( - + {roundKey > 0 && round && ( + )} ) diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 7a30cea..55024e8 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -48,6 +48,8 @@ interface RoomState { mediaSync: MediaSyncPayload | null boomKey: number 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 @@ -55,6 +57,7 @@ interface RoomState { startGame: (rounds: RoundConfig[]) => Promise submitTrack: (youtubeUrl: string) => Promise vote: (choiceIndex: number) => void + voteText: (text: string) => void voteBlindtest: (answer: BlindtestAnswer) => void mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void boom: () => void @@ -77,6 +80,7 @@ export const useRoomStore = create((set, get) => ({ mediaSync: null, boomKey: 0, roundKey: 0, + roundModeChanged: false, createRoom: (playerName) => new Promise((resolve, reject) => { @@ -141,6 +145,14 @@ export const useRoomStore = create((set, get) => ({ 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 @@ -170,6 +182,7 @@ export const useRoomStore = create((set, get) => ({ mediaSync: null, boomKey: 0, roundKey: 0, + roundModeChanged: false, }), })) @@ -194,6 +207,7 @@ socket.on("round:start", (payload) => hasVoted: false, mediaSync: null, roundKey: s.roundKey + 1, + roundModeChanged: (s.round?.type ?? null) !== payload.type, })) ) socket.on("round:voteAck", (voteProgress) => diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index a56a6f6..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" @@ -28,7 +31,7 @@ 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 + gameType: GameType blindtestMode: BlindtestMode /** Durée d'une manche en secondes (def. 60). */ roundDuration: number @@ -40,7 +43,7 @@ export interface RoomSettings { /** Réglages par défaut d'une nouvelle room. */ export const DEFAULT_ROOM_SETTINGS: RoomSettings = { - gameType: "quiz", + gameType: "mixed", blindtestMode: "title_artist", roundDuration: 60, tracksPerPlayer: 2, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index e03de62..8b9a7b1 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -4,6 +4,7 @@ import type { Answer, BlindtestMode, + GameType, PlayerScore, RoomSnapshot, RoundConfig, @@ -27,7 +28,7 @@ export interface RoomJoinPayload { } export interface UpdateSettingsPayload { - gameType: RoundType + gameType: GameType blindtestMode: BlindtestMode roundDuration: number tracksPerPlayer: number 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. */ From a9eb6098f261e6a60f44019bd17a0b6e0209d669 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 14:30:47 +0200 Subject: [PATCH 3/9] feat(blindtest): contributor-as-DJ scoring, 3-player gate, 16:9 player + seek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DJ is now the track's contributor (controls their own song); their identity is sent only to them so "who added" stays non-trivial. The contributor doesn't vote and is excluded from the eligible-voters count. - Scoring: the contributor earns nothing except in who_added/mixed, where they get a misdirection bonus (50) per player who guesses the wrong adder. - Blindtest (and mixed) require >= 3 connected players: lobby disables the options with a hint, and game:start rejects with NEED_THREE. - Player card is now 16:9 and full width — the video banner shows at reveal; DJ gets a seek bar (current/duration) to jump anywhere, plus play/pause/restart. - Mixed game order is shuffled (no predictable quiz/blindtest alternation). Verified e2e (3 players): guard rejects at 2, DJ id hidden from others, misdirection scoring correct. 23 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 24 ++- .../modes/blindtest/blindtest-round.test.ts | 24 ++- .../game/modes/blindtest/blindtest-round.ts | 82 ++++++---- apps/server/src/handlers.ts | 12 ++ apps/web/src/components/blindtest-view.tsx | 145 +++++++++++++----- apps/web/src/components/lobby-view.tsx | 71 +++++---- apps/web/src/lib/youtube.ts | 2 + 7 files changed, 255 insertions(+), 105 deletions(-) diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 7df50c9..7b9771f 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -127,13 +127,26 @@ export class GameEngine implements RoomGameController { } this.room.status = "in_round" this.broadcastState() - this.io.to(this.room.code).emit("round:start", { + // L'identité du DJ (= contributeur du titre) est privée : seul le DJ la + // reçoit, sinon "qui l'a ajouté" deviendrait trivial. + const base = { type: round.type, - djId: this.runtime.djId ?? undefined, startsAt: startedAt, endsAt, payload: start.payload, - }) + } + const djSocketId = this.runtime.djId + ? this.room.players.get(this.runtime.djId)?.socketId + : null + if (djSocketId) { + this.io.to(djSocketId).emit("round:start", { + ...base, + djId: this.runtime.djId ?? undefined, + }) + this.io.to(this.room.code).except(djSocketId).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) => { @@ -177,10 +190,11 @@ export class GameEngine implements RoomGameController { } } - /** Votants éligibles : tous les joueurs connectés (le DJ inclus). */ + /** Votants éligibles : joueurs connectés, hors DJ (= contributeur, ne vote pas). */ private eligibleVoters(): string[] { + const djId = this.runtime?.djId return [...this.room.players.values()] - .filter((p) => p.connected) + .filter((p) => p.connected && p.id !== djId) .map((p) => p.id) } diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts index 30a8eb1..ef6f599 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -44,19 +44,37 @@ describe("match", () => { }) describe("BlindtestRound", () => { - test("start tire un DJ neutre (jamais le contributeur)", () => { + test("start : le DJ est le contributeur du titre", () => { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") - const { player: b } = rooms.join(room.code, "Bob", "sb") + 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.djId).toBe(a.id) // Alice a ajouté le titre → elle est DJ 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 }) // ignoré (contributrice) + 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") diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index 59199cf..17e504f 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -1,5 +1,6 @@ -// Épreuve Blindtest : un titre = une manche. Un DJ neutre (jamais le -// contributeur) pilote la lecture ; tout le monde vote à l'aveugle. +// É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, @@ -18,24 +19,15 @@ 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 -} - -/** Calcule le résultat d'un vote selon le mode. */ -function resultFor( +/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */ +function voterResult( mode: BlindtestMode, answer: Answer | undefined, track: BlindtestTrack @@ -68,7 +60,8 @@ export class BlindtestRound implements GameRound { start(room: ServerRoom): RoundStart { const track = takeTrack(room) - const djId = track ? pickDj(room, track.submittedBy) : null + // Le DJ EST le contributeur : il lance/pilote son propre titre. + const djId = track ? track.submittedBy : null const payload: BlindtestRoundPayload | null = track ? { trackId: track.id, youtubeId: track.youtubeId } : null @@ -76,23 +69,50 @@ export class BlindtestRound implements GameRound { } submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { - // Premier vote verrouillé (idempotent). - if (ctx.votes.has(playerId)) { + const { track } = ctx.data as BlindtestRoundData + // Le contributeur (DJ) ne vote pas ; premier vote verrouillé pour les autres. + if (playerId === track.submittedBy || 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 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: { @@ -102,22 +122,16 @@ export class BlindtestRound implements GameRound { submittedBy: track.submittedBy, submittedByName: submitter?.name ?? "?", }, - perPlayerResult, + perPlayerResult: this.buildResults(ctx), } } score(ctx: RoundContext): ScoreDelta[] { - const { track } = ctx.data as BlindtestRoundData - const mode = ctx.room.settings.blindtestMode + const perPlayer = this.buildResults(ctx) 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 }) + 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/handlers.ts b/apps/server/src/handlers.ts index 0fd0e96..60ea318 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -128,6 +128,18 @@ export function registerRoomHandlers( }) return } + const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") + const connected = [...room.players.values()].filter((p) => p.connected).length + if (hasBlindtest && connected < 3) { + ack({ + ok: false, + error: { + code: "NEED_THREE", + message: "Le blindtest nécessite au moins 3 joueurs.", + }, + }) + 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. diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index e6b9596..8925185 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -11,13 +11,19 @@ import type { } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" -import { useYoutube } from "@/lib/youtube" +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) @@ -58,19 +64,6 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { } }, [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 (
@@ -86,9 +79,13 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )}
- {/* Pochette : le lecteur YouTube est caché derrière (son seulement). */} -
-
+ {/* 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 && ( -
- - Tu es le DJ 🎧 - -
- - - -
-
+ )} - {!showReveal && ( + {!isDj && !showReveal && ( 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 🎧 — c'est ton titre, fais-le deviner 🤫 + +
+ + + +
+
+ {fmt(pos)} + { + const v = Number(e.target.value) + setPos(v) + api.seek(v) + mediaControl("seek", v) + }} + className="accent-primary flex-1" + /> + {fmt(dur)} +
+
+ ) +} + function VoteForm({ mode, snapshot, @@ -238,9 +307,11 @@ function VoteForm({ function RevealCard({ truth, + isDj, result, }: { truth: BlindtestRevealTruth + isDj: boolean result?: BlindtestPerPlayerResult[string] }) { return ( @@ -252,11 +323,13 @@ function RevealCard({ {truth.submittedByName}

- {result && ( + {result && (result.points > 0 || !isDj) && (

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

)}
diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 8eb8375..6e948f8 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -25,6 +25,15 @@ const MODE_LABELS: Record = { 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 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, @@ -37,21 +46,12 @@ function buildRounds( if (gameType === "blindtest") { return Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) } - // Mixte : on alterne quiz / blindtest pour multiplier les changements de mode. - const rounds: RoundConfig[] = [] - let q = quizCount - let b = totalTracks - while (q > 0 || b > 0) { - if (q > 0) { - rounds.push({ type: "quiz" }) - q-- - } - if (b > 0) { - rounds.push({ type: "blindtest" }) - b-- - } - } - return rounds + // 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 }) { @@ -71,8 +71,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const showQuiz = gameType === "quiz" || gameType === "mixed" const showBlindtest = gameType === "blindtest" || gameType === "mixed" + // 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 + const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks) - const canStart = rounds.length > 0 + const canStart = + rounds.length > 0 && (!showBlindtest || blindtestAvailable) async function start() { setError(null) @@ -117,17 +122,29 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {isHost && ( -
- {GAME_TYPES.map((t) => ( - - ))} +
+
+ {GAME_TYPES.map((t) => { + const needsThree = t.value !== "quiz" && !blindtestAvailable + return ( + + ) + })} +
+ {!blindtestAvailable && ( +

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

+ )}
)} diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts index b4308c4..13f47a0 100644 --- a/apps/web/src/lib/youtube.ts +++ b/apps/web/src/lib/youtube.ts @@ -28,6 +28,7 @@ export interface YoutubeApi { pause: () => void seek: (seconds: number) => void time: () => number + duration: () => number } /** @@ -89,6 +90,7 @@ export function useYoutube(youtubeId: string) { pause: () => playerRef.current?.pauseVideo(), seek: (seconds) => playerRef.current?.seekTo(seconds, true), time: () => playerRef.current?.getCurrentTime() ?? 0, + duration: () => playerRef.current?.getDuration() ?? 0, }), [] ) From a2e3fae858bc61c6f3fbd04d6ca65605f74d205e Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 14:40:02 +0200 Subject: [PATCH 4/9] fix(blindtest): keep neutral DJ; only the contributor is misdirection-scored Revert the DJ change: the DJ is again a neutral player (never the track's contributor), broadcast to all and counted as an eligible voter (votes blind). The contributor is a normal voter whose vote never scores on their own track; they only earn the who_added/mixed misdirection bonus (+50 per wrong guess). - engine: restore single round:start broadcast + DJ-included eligible voters - blindtest-round: restore neutral pickDj; contributor may vote (ignored in scoring) so the round can still end on "all voted" - client: DJ controls + everyone (DJ included) votes; reveal message simplified Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 24 ++++--------------- .../modes/blindtest/blindtest-round.test.ts | 8 +++---- .../game/modes/blindtest/blindtest-round.ts | 21 ++++++++++++---- apps/web/src/components/blindtest-view.tsx | 13 ++++------ 4 files changed, 29 insertions(+), 37 deletions(-) diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 7b9771f..2aedfce 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -127,26 +127,13 @@ export class GameEngine implements RoomGameController { } this.room.status = "in_round" this.broadcastState() - // L'identité du DJ (= contributeur du titre) est privée : seul le DJ la - // reçoit, sinon "qui l'a ajouté" deviendrait trivial. - const base = { + this.io.to(this.room.code).emit("round:start", { type: round.type, + djId: this.runtime.djId ?? undefined, startsAt: startedAt, endsAt, payload: start.payload, - } - const djSocketId = this.runtime.djId - ? this.room.players.get(this.runtime.djId)?.socketId - : null - if (djSocketId) { - this.io.to(djSocketId).emit("round:start", { - ...base, - djId: this.runtime.djId ?? undefined, - }) - this.io.to(this.room.code).except(djSocketId).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) => { @@ -190,11 +177,10 @@ export class GameEngine implements RoomGameController { } } - /** Votants éligibles : joueurs connectés, hors DJ (= contributeur, ne vote pas). */ + /** Votants éligibles : tous les joueurs connectés (le DJ neutre vote aussi). */ private eligibleVoters(): string[] { - const djId = this.runtime?.djId return [...this.room.players.values()] - .filter((p) => p.connected && p.id !== djId) + .filter((p) => p.connected) .map((p) => p.id) } diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts index ef6f599..cdeb1ee 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -44,16 +44,16 @@ describe("match", () => { }) describe("BlindtestRound", () => { - test("start : le DJ est le contributeur du titre", () => { + test("start : le DJ est neutre (jamais le contributeur)", () => { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") - rooms.join(room.code, "Bob", "sb") + 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(a.id) // Alice a ajouté le titre → elle est DJ + expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice) expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" }) }) @@ -67,7 +67,7 @@ describe("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 }) // ignoré (contributrice) + 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 }) diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index 17e504f..14b61ef 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -26,6 +26,17 @@ 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, @@ -60,8 +71,8 @@ export class BlindtestRound implements GameRound { start(room: ServerRoom): RoundStart { const track = takeTrack(room) - // Le DJ EST le contributeur : il lance/pilote son propre titre. - const djId = track ? track.submittedBy : null + // 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 @@ -69,9 +80,9 @@ export class BlindtestRound implements GameRound { } submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { - const { track } = ctx.data as BlindtestRoundData - // Le contributeur (DJ) ne vote pas ; premier vote verrouillé pour les autres. - if (playerId === track.submittedBy || ctx.votes.has(playerId)) { + // 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) diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index 8925185..5354eab 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -102,7 +102,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )} - {!isDj && !showReveal && ( + {!showReveal && ( - Tu es le DJ 🎧 — c'est ton titre, fais-le deviner 🤫 + Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)
From a2dc687ac0f359116d85acdac5d64838a7dd3049 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 14:51:19 +0200 Subject: [PATCH 5/9] fix(web): lobby falls back to quiz UI below 3 players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default game type stays "mixed", but while fewer than 3 players are connected the lobby renders the quiz config only (Quiz highlighted, Mixed/Blindtest disabled) instead of showing blindtest settings to a lone host. It switches back to the stored type automatically once 3 players are present — no manual click needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/lobby-view.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 6e948f8..0197898 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -68,16 +68,19 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) const myCount = snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0 - const showQuiz = gameType === "quiz" || gameType === "mixed" - const showBlindtest = gameType === "blindtest" || gameType === "mixed" // 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(gameType, showQuiz ? count : 0, totalTracks) - const canStart = - rounds.length > 0 && (!showBlindtest || blindtestAvailable) + const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks) + const canStart = rounds.length > 0 async function start() { setError(null) @@ -130,7 +133,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
+ ) +} + function VoteForm({ mode, snapshot, diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 28c1ae9..b4d47f4 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -113,6 +113,8 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const finalScores = useRoomStore((s) => s.finalScores) 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 +196,22 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} - - - +
+ {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 0197898..c8b7124 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,4 +1,5 @@ import { useState } from "react" +import { Trash2 } from "lucide-react" import type { BlindtestMode, GameType, @@ -225,6 +226,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { ) } +interface MyTrack { + trackId: string + title: string + youtubeId: string +} + function TrackSubmission({ tracksPerPlayer, myCount, @@ -233,10 +240,11 @@ function TrackSubmission({ 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 [accepted, setAccepted] = useState([]) + const [tracks, setTracks] = useState([]) const quotaReached = myCount >= tracksPerPlayer async function submit() { @@ -246,8 +254,11 @@ function TrackSubmission({ setSubmitting(true) setFeedback(null) const res = await submitTrack(url.trim()) - if (res.accepted) { - setAccepted((a) => [...a, res.title ?? 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é") @@ -255,38 +266,65 @@ function TrackSubmission({ 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}) -
- setUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - /> - -
- {feedback &&

{feedback}

} - {accepted.length > 0 && ( -
    - {accepted.map((t, i) => ( -
  • - ✓ {t} + + {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/store/room.ts b/apps/web/src/store/room.ts index 55024e8..6955ae1 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -20,6 +20,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 @@ -56,6 +58,8 @@ interface RoomState { 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 @@ -137,6 +141,15 @@ export const useRoomStore = create((set, get) => ({ 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().hasVoted) { return @@ -189,7 +202,22 @@ export const useRoomStore = create((set, get) => ({ // 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, + mediaSync: null, + } + : { snapshot } + ) +) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("round:start", (payload) => @@ -197,6 +225,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, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 8b9a7b1..b3f35fe 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -46,12 +46,19 @@ 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 { type: RoundType djId?: string @@ -59,6 +66,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 } @@ -118,11 +127,16 @@ export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void + "lobby:return": () => void "game:start": (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 } From 1071a59b681fd42f0206603edacd6231abfdf511 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 15:32:24 +0200 Subject: [PATCH 7/9] feat: pick pseudo inside the room + shareable invite link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure onboarding (option B): - home is just Create / Join (code) — no name field - name is chosen in the room: room:create/join no longer require a name; new player:setName event. Nameless players are hidden from the snapshot (players/scores/submissions) and excluded from eligible voters and the 3-player blindtest gate until they pick a pseudo. - PseudoScreen with live DiceBear avatar preview gates RoomPage until set - /join/:code auto-joins then redirects to the room (seamless invite links) - RoomCode gains a "copier le lien" button (origin/join/CODE) Verified e2e: nameless players excluded from the room until setName. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 2 +- apps/server/src/handlers.ts | 33 +++++++---- apps/server/src/rooms.ts | 12 ++-- apps/web/src/App.tsx | 4 ++ apps/web/src/components/pseudo-screen.tsx | 45 +++++++++++++++ apps/web/src/components/room-code.tsx | 67 ++++++++++++++--------- apps/web/src/pages/home.tsx | 20 ++----- apps/web/src/pages/join.tsx | 42 ++++++++++++++ apps/web/src/pages/room.tsx | 21 ++++++- apps/web/src/store/room.ts | 37 ++++++++++--- packages/shared/src/events.ts | 9 ++- 11 files changed, 223 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/pseudo-screen.tsx create mode 100644 apps/web/src/pages/join.tsx diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index a34be3a..9b11409 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -198,7 +198,7 @@ export class GameEngine implements RoomGameController { private eligibleVoters(): string[] { const secret = this.runtime?.secretPlayerId return [...this.room.players.values()] - .filter((p) => p.connected && p.id !== secret) + .filter((p) => p.connected && p.name !== "" && p.id !== secret) .map((p) => p.id) } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 05657c9..2c5bf82 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -53,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 @@ -71,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 @@ -89,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 @@ -129,7 +138,9 @@ export function registerRoomHandlers( return } const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") - const connected = [...room.players.values()].filter((p) => p.connected).length + const connected = [...room.players.values()].filter( + (p) => p.connected && p.name !== "" + ).length if (hasBlindtest && connected < 3) { ack({ ok: false, diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index bffbd5c..96be50b 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -112,23 +112,25 @@ 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: [...room.players.values()].map((p) => ({ + 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/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/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/pages/home.tsx b/apps/web/src/pages/home.tsx index c9d305c..6aebc56 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -12,18 +12,15 @@ export function HomePage() { 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 +33,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") @@ -57,15 +54,7 @@ export function HomePage() {

- setName(e.target.value)} - /> - - @@ -82,10 +71,11 @@ export function HomePage() { value={code} maxLength={4} onChange={(e) => setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && code.trim() && handleJoin()} /> + + + ) : ( +

+ Connexion à la room {code}… +

+ )} +
+ ) +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index ee937fc..5c00307 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -9,19 +9,22 @@ 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 (

@@ -34,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" || diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 6955ae1..94f018c 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -37,6 +37,8 @@ interface RoomState { connected: boolean 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 @@ -53,8 +55,9 @@ interface RoomState { /** 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 + createRoom: () => Promise + joinRoom: (roomCode: string) => Promise + setName: (name: string) => void updateSettings: (partial: Partial) => void startGame: (rounds: RoundConfig[]) => Promise submitTrack: (youtubeUrl: string) => Promise @@ -73,6 +76,7 @@ export const useRoomStore = create((set, get) => ({ connected: socket.connected, playerId: null, roomCode: null, + pseudoSet: false, snapshot: null, lastError: null, round: null, @@ -86,11 +90,15 @@ export const useRoomStore = create((set, get) => ({ 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 }) @@ -99,11 +107,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 }) @@ -112,6 +124,14 @@ export const useRoomStore = create((set, get) => ({ }) }), + 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", { @@ -184,6 +204,7 @@ export const useRoomStore = create((set, get) => ({ reset: () => set({ roomCode: null, + pseudoSet: false, snapshot: null, lastError: null, round: null, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index b3f35fe..747f6ff 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -14,7 +14,7 @@ import type { // --- Payloads ------------------------------------------------------------ export interface RoomCreatePayload { - playerName: string + playerName?: string } export interface RoomCreatedPayload { @@ -24,7 +24,11 @@ export interface RoomCreatedPayload { export interface RoomJoinPayload { roomCode: string - playerName: string + playerName?: string +} + +export interface SetNamePayload { + name: string } export interface UpdateSettingsPayload { @@ -126,6 +130,7 @@ 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 From 3f0cab8f5fa69ca018885321056dc19457249674 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 16:04:14 +0200 Subject: [PATCH 8/9] feat: end-of-game track recap, no self-vote in who_added, festive home + icons - game:end now carries the played blindtest tracks (title/artist/youtubeId/ url/submittedByName); the end screen lists them as cards with a YouTube open link so players can recover/export the songs - who_added vote no longer lets you pick yourself (filtered out) - home page: animated halo, floating geek emojis, springy gradient title, hover/tap on buttons (Framer Motion) + Lucide icons - lobby settings get Lucide icons (game type, quiz, blindtest mode, tracks, start) Verified e2e: game:end includes the track list with links. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.ts | 13 +++- apps/web/src/components/blindtest-view.tsx | 33 +++++---- apps/web/src/components/game-end-view.tsx | 43 +++++++++++- apps/web/src/components/lobby-view.tsx | 45 ++++++++---- apps/web/src/pages/home.tsx | 81 ++++++++++++++++++---- apps/web/src/store/room.ts | 9 ++- packages/shared/src/blindtest.ts | 9 +++ packages/shared/src/events.ts | 3 + 8 files changed, 187 insertions(+), 49 deletions(-) diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 9b11409..8fe2b74 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -72,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. */ diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index 739ea5f..a96ea40 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -295,23 +295,22 @@ function VoteForm({ )} {needsWho && (
- {snapshot.players.map((p) => ( - - ))} + {snapshot.players + .filter((p) => p.id !== playerId) + .map((p) => ( + + ))}
)} + + + ))} + + + )} +
{isHost ? ( ) })} @@ -154,7 +163,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {isHost && showQuiz && (
- Questions de quiz + + Questions de quiz +
{QUESTION_OPTIONS.map((n) => (
- Titres par joueur + + Titres par joueur +
{TRACKS_OPTIONS.map((n) => ( ) : (

diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index 6aebc56..c124839 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -1,11 +1,23 @@ 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) @@ -43,20 +55,63 @@ export function HomePage() { } return ( -

-
+
+ {/* Halo animé */} + + {/* Emojis flottants */} + {FLOATERS.map((f) => ( + + {f.emoji} + + ))} + +
-

+ NerdWare -

-

- Party game culture geek + +

+ Party game culture geek

- + + +
@@ -66,8 +121,8 @@ export function HomePage() {
setCode(e.target.value)} @@ -78,7 +133,7 @@ export function HomePage() { disabled={!connected || busy || code.trim().length === 0} onClick={handleJoin} > - Rejoindre + Rejoindre
@@ -90,7 +145,7 @@ export function HomePage() { {error && (

{error}

)} -
+
) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 94f018c..b134866 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -2,6 +2,7 @@ import { create } from "zustand" import { DEFAULT_ROOM_SETTINGS, type BlindtestAnswer, + type BlindtestTrackInfo, type ErrorPayload, type MediaControlPayload, type MediaSyncPayload, @@ -49,6 +50,7 @@ interface RoomState { myChoiceIndex: number | null hasVoted: boolean finalScores: PlayerScore[] | null + gameTracks: BlindtestTrackInfo[] | null mediaSync: MediaSyncPayload | null boomKey: number roundKey: number @@ -85,6 +87,7 @@ export const useRoomStore = create((set, get) => ({ myChoiceIndex: null, hasVoted: false, finalScores: null, + gameTracks: null, mediaSync: null, boomKey: 0, roundKey: 0, @@ -213,6 +216,7 @@ export const useRoomStore = create((set, get) => ({ myChoiceIndex: null, hasVoted: false, finalScores: null, + gameTracks: null, mediaSync: null, boomKey: 0, roundKey: 0, @@ -234,6 +238,7 @@ socket.on("room:state", (snapshot) => myChoiceIndex: null, hasVoted: false, finalScores: null, + gameTracks: null, mediaSync: null, } : { snapshot } @@ -265,6 +270,6 @@ socket.on("round:voteAck", (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 }) +socket.on("game:end", ({ finalScores, tracks }) => + useRoomStore.setState({ finalScores, gameTracks: tracks ?? null }) ) diff --git a/packages/shared/src/blindtest.ts b/packages/shared/src/blindtest.ts index 9c37acf..5382d15 100644 --- a/packages/shared/src/blindtest.ts +++ b/packages/shared/src/blindtest.ts @@ -26,3 +26,12 @@ export interface BlindtestPlayerResult { /** 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/events.ts b/packages/shared/src/events.ts index 747f6ff..2c1f023 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -10,6 +10,7 @@ import type { RoundConfig, RoundType, } from "./domain" +import type { BlindtestTrackInfo } from "./blindtest" // --- Payloads ------------------------------------------------------------ @@ -113,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 } From ff84dc4f6b3a12d334608d643ef7d538af297aae Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 16:19:31 +0200 Subject: [PATCH 9/9] feat(lobby): block start until everyone submitted; tracks count stepper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blindtest/mixed: the host can't start until every connected player has submitted their track quota — client disables the button with a hint, and game:start rejects with TRACKS_PENDING (authoritative) - tracks-per-player is now a number stepper (− / + buttons + free typing, clamped 1..10) instead of fixed 1/2/3 buttons Verified e2e: start rejected while a player hasn't submitted, accepted once all did. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/handlers.ts | 19 ++++++ apps/web/src/components/lobby-view.tsx | 88 +++++++++++++++++++++----- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 2c5bf82..4b15c83 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -151,6 +151,25 @@ export function registerRoomHandlers( }) 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. diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index cd449b3..a34afb0 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -3,8 +3,10 @@ import { Brain, Headphones, ListMusic, + Minus, Music, Play, + Plus, Shuffle, Trash2, type LucideIcon, @@ -20,7 +22,7 @@ import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" const QUESTION_OPTIONS = [3, 5, 10] -const TRACKS_OPTIONS = [1, 2, 3] +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 }, @@ -35,6 +37,53 @@ const MODE_LABELS: Record = { 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--) { @@ -90,7 +139,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { effectiveType === "blindtest" || effectiveType === "mixed" const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks) - const canStart = rounds.length > 0 + // 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) @@ -205,18 +259,10 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { Titres par joueur -
- {TRACKS_OPTIONS.map((n) => ( - - ))} -
+ updateSettings({ tracksPerPlayer: v })} + />
)} @@ -226,9 +272,17 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { )} {isHost ? ( - +
+ + {showBlindtest && !allSubmitted && ( +

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

+ )} +
) : (

En attente du lancement par l'hôte…