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>
217 lines
6.7 KiB
TypeScript
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)
|
|
})
|
|
}
|