nerdware/apps/server/src/handlers.ts
AyoubBenziza 42a36fc442 feat: blindtest needs 2 real players + bot difficulty setting
- 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>
2026-06-11 18:03:03 +02:00

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