'use strict'; const path = require('path'); const http = require('http'); const crypto = require('crypto'); const express = require('express'); const QRCode = require('qrcode'); const { Server } = require('socket.io'); const PORT = process.env.PORT || 3000; const app = express(); const server = http.createServer(app); const io = new Server(server); app.use(express.static(path.join(__dirname, 'public'))); app.get('/healthz', (_req, res) => res.send('ok')); app.get('/qr.svg', async (req, res) => { const text = String(req.query.text || '').slice(0, 500); if (!text) return res.status(400).end(); const svg = await QRCode.toString(text, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' }); res.type('image/svg+xml').send(svg); }); /* ---------- État des rooms (en mémoire) ---------- */ // phases : lobby | playing | buzzed | result | ended const rooms = new Map(); const ROOM_RE = /^[A-Z0-9]{4,8}$/; const TOKEN_RE = /^[A-Za-z0-9-]{8,40}$/; const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; function newRoomId() { let id; do { id = Array.from({ length: 5 }, () => CHARS[crypto.randomInt(CHARS.length)]).join(''); } while (rooms.has(id)); return id; } function createRoom(id) { const room = { id, phase: 'lobby', players: new Map(), buzzer: null, lockedOut: new Set(), lastActive: Date.now() }; rooms.set(id, room); return room; } function publicState(room) { return { phase: room.phase, buzzer: room.buzzer, lockedOut: [...room.lockedOut], players: [...room.players.values()].map(p => ({ id: p.id, name: p.name, score: p.score, connected: p.connected })), }; } function broadcast(room) { room.lastActive = Date.now(); io.to(room.id).emit('state', publicState(room)); } function masterRoom(socket) { if (socket.data.role !== 'master') return null; return rooms.get(socket.data.roomId) || null; } io.on('connection', socket => { /* ----- Master ----- */ socket.on('master:create', (data, ack) => { ack = typeof ack === 'function' ? ack : () => {}; let id = String((data && data.roomId) || '').toUpperCase(); let room = ROOM_RE.test(id) ? rooms.get(id) : null; if (!room) room = createRoom(ROOM_RE.test(id) ? id : newRoomId()); socket.data.role = 'master'; socket.data.roomId = room.id; socket.join(room.id); ack({ roomId: room.id }); broadcast(room); }); socket.on('master:start', () => { const room = masterRoom(socket); if (!room || room.phase === 'ended') return; room.phase = 'playing'; room.buzzer = null; room.lockedOut.clear(); broadcast(room); }); socket.on('master:judge', correct => { const room = masterRoom(socket); if (!room || room.phase !== 'buzzed' || !room.buzzer) return; const player = [...room.players.values()].find(p => p.id === room.buzzer.id); if (correct) { if (player) player.score += 1; room.phase = 'result'; // le buzzer reste affiché comme gagnant } else { room.lockedOut.add(room.buzzer.id); room.buzzer = null; room.phase = 'playing'; // la vidéo reprend, ce joueur est bloqué pour ce morceau } broadcast(room); }); socket.on('master:next', () => { const room = masterRoom(socket); if (!room || room.phase === 'ended') return; room.phase = 'lobby'; room.buzzer = null; room.lockedOut.clear(); broadcast(room); }); socket.on('master:end', () => { const room = masterRoom(socket); if (!room) return; room.phase = 'ended'; room.buzzer = null; broadcast(room); }); socket.on('master:newgame', () => { const room = masterRoom(socket); if (!room) return; room.players.forEach(p => { p.score = 0; }); room.phase = 'lobby'; room.buzzer = null; room.lockedOut.clear(); broadcast(room); }); /* ----- Joueurs ----- */ socket.on('player:join', (data, ack) => { ack = typeof ack === 'function' ? ack : () => {}; data = data || {}; const room = rooms.get(String(data.roomId || '').toUpperCase()); if (!room) return ack({ error: 'no_room' }); const token = TOKEN_RE.test(data.token || '') ? data.token : null; let player = token ? room.players.get(token) : null; if (!player) { const name = String(data.name || '').trim().replace(/\s+/g, ' ').slice(0, 20); if (!name) return ack({ error: 'no_name' }); const taken = [...room.players.values()].some(p => p.name.toLowerCase() === name.toLowerCase()); if (taken) return ack({ error: 'name_taken' }); const t = token || crypto.randomUUID(); player = { id: crypto.randomBytes(4).toString('hex'), token: t, name, score: 0, connected: true }; room.players.set(t, player); } player.connected = true; socket.data.role = 'player'; socket.data.roomId = room.id; socket.data.token = player.token; socket.join(room.id); ack({ token: player.token, id: player.id, name: player.name }); broadcast(room); }); socket.on('player:buzz', () => { if (socket.data.role !== 'player') return; const room = rooms.get(socket.data.roomId); const player = room && room.players.get(socket.data.token); if (!room || !player) return; if (room.phase !== 'playing' || room.lockedOut.has(player.id)) return; room.phase = 'buzzed'; // le premier message reçu gagne room.buzzer = { id: player.id, name: player.name }; broadcast(room); }); socket.on('disconnect', () => { if (socket.data.role !== 'player') return; const room = rooms.get(socket.data.roomId); const player = room && room.players.get(socket.data.token); if (player) { player.connected = false; broadcast(room); } }); }); // Nettoyage des rooms inactives (12 h) setInterval(() => { const limit = Date.now() - 12 * 3600 * 1000; for (const [id, room] of rooms) if (room.lastActive < limit) rooms.delete(id); }, 30 * 60 * 1000).unref(); server.listen(PORT, () => console.log(`Blind test sur le port ${PORT}`));