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 cdeb1ee..750e0d1 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -44,7 +44,7 @@ describe("match", () => { }) describe("BlindtestRound", () => { - test("start : le DJ est neutre (jamais le contributeur)", () => { + test("start : le DJ est un humain connecté (le contributeur peut tomber DJ)", () => { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") const { player: b } = rooms.join(room.code, "Bob", "sb") @@ -53,10 +53,24 @@ describe("BlindtestRound", () => { const round = new BlindtestRound() const start = round.start(room) - expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice) + // N'importe quel humain (Alice incluse), jamais un bot ni null. + expect([a.id, b.id]).toContain(start.djId!) expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" }) }) + test("start : un bot n'est jamais DJ", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + rooms.addBot(room) + rooms.addBot(room) + room.blindtestTracks = [track(a.id)] + prepareBlindtestForRoom(room) + + const round = new BlindtestRound() + // Alice est la seule humaine → DJ forcément Alice (les bots sont exclus). + expect(round.start(room).djId).toBe(a.id) + }) + test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => { 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 53f86c9..0b0d46f 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -32,10 +32,14 @@ interface BlindtestRoundData { track: BlindtestTrack } -/** Tire un DJ neutre : un humain connecté qui n'a pas soumis ce titre. */ -function pickDj(room: ServerRoom, submittedBy: string): string | null { +// Le blindtest dure plus longtemps (écoute + saisie). +const BLINDTEST_DURATION_SEC = 90 + +/** Tire un DJ : n'importe quel humain connecté (y compris le contributeur, pour + * ne pas donner d'« immunité » à celui qui n'est jamais tiré). */ +function pickDj(room: ServerRoom): string | null { const eligible = [...room.players.values()].filter( - (p) => p.connected && !p.isBot && p.id !== submittedBy + (p) => p.connected && !p.isBot ) if (eligible.length === 0) { return null @@ -77,13 +81,14 @@ export class BlindtestRound implements GameRound { start(room: ServerRoom): RoundStart { const track = takeTrack(room) - // DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle. - const djId = track ? pickDj(room, track.submittedBy) : null + // DJ = n'importe quel humain (le contributeur peut tomber DJ). + const djId = track ? pickDj(room) : null const payload: BlindtestRoundPayload | null = track ? { trackId: track.id, youtubeId: track.youtubeId } : null return { djId, + durationSec: BLINDTEST_DURATION_SEC, payload, secretPlayerId: track?.submittedBy, data: track ? { track } : null, diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index d39772f..5749264 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -61,6 +61,9 @@ export function registerRoomHandlers( }) } + // Synchro d'horloge : le client mesure l'offset avec l'horloge serveur. + socket.on("time:sync", (ack) => ack(Date.now())) + socket.on("room:create", (payload, ack) => { // Le pseudo est choisi dans la room (player:setName) : on crée sans nom. const name = cleanName(payload?.playerName) ?? "" diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index 2b1dac3..026eb76 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -15,6 +15,7 @@ import { useYoutube, type YoutubeApi } from "@/lib/youtube" import { Countdown } from "@/components/countdown" import { Avatar } from "@/components/avatar" import { useI18n } from "@/i18n/context" +import { serverNow } from "@/lib/clock" 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" @@ -46,12 +47,31 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { // doit "armer" le son une fois (le DJ a déjà cliqué Play, donc dispensé). const [audioArmed, setAudioArmed] = useState(false) + // Volume local (par joueur), mémorisé entre les manches/parties. + const [volume, setVolume] = useState(() => { + const v = Number(localStorage.getItem("nerdware-volume")) + return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 100 + }) + useEffect(() => { + if (ready) { + api.setVolume(volume) + } + }, [ready, volume, api]) + function changeVolume(v: number) { + setVolume(v) + try { + localStorage.setItem("nerdware-volume", String(v)) + } catch { + // ignore + } + } + // 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 + const elapsed = (serverNow() - mediaSync.atServerTs) / 1000 if (mediaSync.action === "play") { api.seek(mediaSync.positionSec + Math.max(0, elapsed)) if (audioArmed) { @@ -68,7 +88,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { function armAudio() { setAudioArmed(true) if (mediaSync && mediaSync.action !== "pause") { - const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 + const elapsed = (serverNow() - mediaSync.atServerTs) / 1000 api.seek(mediaSync.positionSec + Math.max(0, elapsed)) } api.play() @@ -125,6 +145,24 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {!showReveal && ( +