nerdware/apps/server/src/handlers.ts
AyoubBenziza 98e06b8206 feat(blindtest): YouTube blindtest mode with neutral DJ + synced playback
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) <noreply@anthropic.com>
2026-06-10 13:02:28 +02:00

217 lines
6.7 KiB
TypeScript

// Handlers Socket.IO du cycle lobby. Enregistrés par socket à la connexion.
import type { Socket } from "socket.io"
import type {
ClientToServerEvents,
ErrorPayload,
InterServerEvents,
ServerToClientEvents,
SocketData,
} 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,
ServerToClientEvents,
InterServerEvents,
SocketData
>
const MAX_NAME_LENGTH = 24
function cleanName(raw: unknown): string | null {
if (typeof raw !== "string") {
return null
}
const name = raw.trim().slice(0, MAX_NAME_LENGTH)
return name.length > 0 ? name : null
}
function toErrorPayload(err: unknown): ErrorPayload {
if (err instanceof RoomError) {
return { code: err.code, message: err.message }
}
return { code: "INTERNAL", message: "Une erreur est survenue." }
}
export function registerRoomHandlers(
io: IoServer,
socket: AppSocket,
rooms: RoomManager
): void {
const broadcastState = (room: ServerRoom) => {
io.to(room.code).emit("room:state", rooms.toSnapshot(room))
}
socket.on("room:create", (payload, ack) => {
const name = cleanName(payload?.playerName)
if (!name) {
ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } })
return
}
try {
const { room, player } = rooms.create(name, socket.id)
socket.data.playerId = player.id
socket.data.roomCode = room.code
socket.join(room.code)
ack({ ok: true, data: { roomCode: room.code, playerId: player.id } })
broadcastState(room)
} catch (err) {
ack({ ok: false, error: toErrorPayload(err) })
}
})
socket.on("room:join", (payload, ack) => {
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
socket.data.roomCode = room.code
socket.join(room.code)
ack({ ok: true, data: { roomCode: room.code, playerId: player.id } })
broadcastState(room)
} catch (err) {
ack({ ok: false, error: toErrorPayload(err) })
}
})
socket.on("lobby:updateSettings", (payload) => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || room.hostId !== socket.data.playerId) {
return
}
room.settings = {
gameType: payload.gameType,
blindtestMode: payload.blindtestMode,
roundDuration: payload.roundDuration,
tracksPerPlayer: payload.tracksPerPlayer,
rounds: payload.rounds,
}
broadcastState(room)
})
socket.on("game:start", (ack) => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || room.hostId !== socket.data.playerId) {
ack({ ok: false, error: { code: "NOT_HOST", message: "Seul l'hôte peut lancer." } })
return
}
if (room.status !== "lobby") {
ack({ ok: false, error: { code: "ALREADY_STARTED", message: "Partie déjà lancée." } })
return
}
if (room.settings.rounds.length === 0) {
ack({ ok: false, error: { code: "NO_ROUNDS", message: "Aucune épreuve configurée." } })
return
}
const unknown = room.settings.rounds.find((r) => !hasRound(r.type))
if (unknown) {
ack({
ok: false,
error: { code: "UNKNOWN_MODE", message: `Épreuve indisponible : ${unknown.type}.` },
})
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.
void prepareRoom(room)
.then(() => {
const engine = new GameEngine(io, rooms, room)
room.game = engine
return engine.run()
})
.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
if (!room || !room.game || !socket.data.playerId) {
return
}
room.game.handleVote(socket.data.playerId, payload.answer)
})
socket.on("disconnect", () => {
const affected = rooms.handleDisconnect(socket.id)
if (!affected) {
return
}
io.to(affected.room.code).emit("player:left", { playerId: affected.playerId })
broadcastState(affected.room)
})
}