// 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) }) }