// 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) => { // 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 socket.data.roomCode = room.code socket.join(room.code) ack({ ok: true, data: { roomCode: room.code, playerId: player.id, token: player.reconnectToken } }) 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() : "" 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, token: player.reconnectToken } }) broadcastState(room) } catch (err) { ack({ ok: false, error: toErrorPayload(err) }) } }) socket.on("room:rejoin", (payload, ack) => { const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : "" const room = rooms.get(code) const player = room?.players.get(payload?.playerId ?? "") if (!room || !player || player.reconnectToken !== payload?.token) { ack({ ok: false, error: { code: "REJOIN_FAILED", message: "Reconnexion impossible." }, }) return } player.connected = true player.socketId = 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, token: player.reconnectToken, }, }) io.to(room.code).emit("player:rejoined", { playerId: player.id }) broadcastState(room) // Re-pousse la manche en cours au socket reconnecté (si partie en cours). room.game?.resync(socket.id, player.id) }) 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 if (!room || room.hostId !== socket.data.playerId) { return } room.settings = { gameType: payload.gameType, mixedModes: payload.mixedModes, blindtestMode: payload.blindtestMode, roundDuration: payload.roundDuration, tracksPerPlayer: payload.tracksPerPlayer, categories: payload.categories, botDifficulty: payload.botDifficulty, rounds: payload.rounds, } broadcastState(room) }) socket.on("lobby:addBot", () => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined if (!room || room.hostId !== socket.data.playerId || room.status !== "lobby") { return } if (rooms.addBot(room)) { broadcastState(room) } }) socket.on("lobby:removeBot", () => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined if (!room || room.hostId !== socket.data.playerId) { return } rooms.removeBot(room) 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 } const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") const active = [...room.players.values()].filter( (p) => p.connected && p.name !== "" ) const connected = active.length const humans = active.filter((p) => !p.isBot).length if (hasBlindtest && (connected < 3 || humans < 2)) { ack({ ok: false, error: { code: "NEED_THREE", message: "Le blindtest nécessite au moins 3 joueurs dont 2 réels.", }, }) return } if (hasBlindtest) { const tpp = room.settings.tracksPerPlayer const pending = [...room.players.values()].some( (p) => p.connected && p.name !== "" && !p.isBot && 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. 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 } const trackId = randomUUID() room.blindtestTracks.push({ id: trackId, youtubeId, url: payload.youtubeUrl, title: meta.title, artist: meta.artist, submittedBy: playerId, }) ack({ accepted: true, trackId, youtubeId, title: meta.title, count: mine.length + 1, }) broadcastState(room) }) socket.on("blindtest:removeTrack", (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({ ok: false }) return } const before = room.blindtestTracks.length room.blindtestTracks = room.blindtestTracks.filter( (t) => !(t.id === payload.trackId && t.submittedBy === playerId) ) const removed = room.blindtestTracks.length < before ack({ ok: removed }) if (removed) { 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("lobby:return", () => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined if (!room || room.hostId !== socket.data.playerId) { return } // Rejouer : retour au lobby, scores remis à zéro, joueurs et titres conservés. room.status = "lobby" room.currentRound = -1 room.game = null for (const playerId of room.scores.keys()) { room.scores.set(playerId, 0) } broadcastState(room) }) socket.on("room:leave", () => { const code = socket.data.roomCode const playerId = socket.data.playerId if (!code || !playerId) { return } socket.leave(code) socket.data.roomCode = undefined socket.data.playerId = undefined const room = rooms.leave(code, playerId) if (room) { io.to(room.code).emit("player:left", { playerId }) broadcastState(room) } }) 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) }) }