- blindtest is only available with >=3 players AND >=2 humans (a bot can't be DJ): lobby gate + tooltips updated, and server game:start rejects otherwise - new botDifficulty setting (easy/normal/hard) drives how often bots answer correctly (quiz + blindtest who-added / title-artist); host picks it in the lobby when at least one bot is present Verified e2e: blindtest start rejected with 1 human + 2 bots (NEED_THREE). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
376 lines
11 KiB
TypeScript
376 lines
11 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) => {
|
|
// 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)
|
|
})
|
|
}
|