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, }), [] )