/** * Tennis Roots โ€“ Server Express + WebSocket * * Funge da hub centrale: * - Serve gli overlay (statici) * - Serve il pannello di controllo (statico) * - Gestisce le connessioni WebSocket per lo stato in tempo reale */ import express from 'express'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = process.env.PORT || 3000; // ---- Stato di default ---- const defaultState = { player1: 'J. Sinner', player2: 'C. Alcaraz', flag1: 'it', flag2: 'es', sets: [ [0, 0], [0, 0], [0, 0], ], games: [0, 0], points: [0, 0], server: 1, currentSet: 0, matchOver: false, deuce: false, advantage: 0, tiebreak: false, tiebreakPoints: [0, 0], tiebreakFirstServer: 1, tournament: 'Wimbledon', round: 'Finale ยท Uomini ยท Singolare', matchStatus: '๐ŸŽพ In corso ยท Set 1', }; let currentState = { ...defaultState }; // ---- Utility punteggio tennis ---- function computeMatchStatus(state) { if (state.matchOver) { const p1Sets = state.sets.reduce((a, s) => a + (s[0] > s[1] ? 1 : 0), 0); const winner = p1Sets >= 2 ? state.player1 : state.player2; return `๐Ÿ† ${winner} vince!`; } const set = state.currentSet + 1; if (state.tiebreak) { return `๐ŸŽพ Tiebreak ยท Set ${set} ยท ${state.tiebreakPoints[0]}-${state.tiebreakPoints[1]}`; } return `๐ŸŽพ In corso ยท Set ${set}`; } function pointLabel(state) { // Tiebreak: mostra punti numerici if (state.tiebreak) { return [String(state.tiebreakPoints[0]), String(state.tiebreakPoints[1])]; } const p1 = state.points[0]; const p2 = state.points[1]; if (state.deuce) { if (state.advantage === 1) return ['AD', '40']; if (state.advantage === 2) return ['40', 'AD']; return ['40', '40']; } const map = ['0', '15', '30', '40']; return [map[p1] || '40', map[p2] || '40']; } // ---- Express ---- const app = express(); const server = createServer(app); // Static files: overlay app.use('/overlay', express.static(resolve(__dirname, '..', 'overlay'))); // Static files: controller app.use('/controller', express.static(resolve(__dirname, '..', 'controller'))); // Redirect root โ†’ controller app.get('/', (req, res) => { res.redirect('/controller/'); }); // API: GET stato corrente app.get('/api/state', (req, res) => { res.json(currentState); }); // API: POST aggiornamento stato (da controller via HTTP) app.post('/api/state', express.json(), (req, res) => { const partial = req.body; currentState = { ...currentState, ...partial, matchStatus: undefined }; currentState.matchStatus = computeMatchStatus(currentState); currentState.timestamp = Date.now(); broadcast({ type: 'STATE_UPDATE', state: currentState }); res.json({ ok: true, state: currentState }); }); // API: POST comando (ADD_POINT, RESET_GAME, RESET_MATCH) app.post('/api/command', express.json(), (req, res) => { const { command, player } = req.body; handleCommand(command, player); res.json({ ok: true, state: currentState }); }); // ---- WebSocket ---- const wss = new WebSocketServer({ server, path: '/ws' }); wss.on('connection', (ws) => { console.log('๐ŸŸข WebSocket client connected'); // Invia stato corrente al nuovo client ws.send(JSON.stringify({ type: 'STATE_UPDATE', state: currentState })); ws.on('message', (data) => { try { const msg = JSON.parse(data.toString()); if (msg.type === 'COMMAND') { handleCommand(msg.command, msg.player); return; } if (msg.type === 'STATE_UPDATE') { currentState = { ...currentState, ...msg.state, matchStatus: undefined }; currentState.matchStatus = computeMatchStatus(currentState); currentState.timestamp = Date.now(); broadcast({ type: 'STATE_UPDATE', state: currentState }, ws); return; } } catch (err) { console.error('โŒ Invalid message:', err.message); } }); ws.on('close', () => { console.log('๐Ÿ”ด WebSocket client disconnected'); }); }); function broadcast(message, exclude = null) { const payload = JSON.stringify(message); for (const client of wss.clients) { if (client !== exclude && client.readyState === 1) { client.send(payload); } } } // ---- Comandi ---- function handleCommand(command, player) { switch (command) { case 'ADD_POINT': addPoint(player); break; case 'RESET_GAME': currentState.points = [0, 0]; currentState.deuce = false; currentState.advantage = 0; currentState.tiebreak = false; currentState.tiebreakPoints = [0, 0]; currentState.tiebreakFirstServer = 1; break; case 'RESET_MATCH': currentState = { ...defaultState, timestamp: Date.now() }; break; case 'SET_DEUCE': currentState.deuce = true; currentState.advantage = 0; currentState.points = [3, 3]; break; case 'SET_SERVER': currentState.server = player; // player = 1 o 2 break; default: return; } currentState.matchStatus = computeMatchStatus(currentState); currentState.timestamp = Date.now(); broadcast({ type: 'STATE_UPDATE', state: currentState }); } function addPoint(player) { const idx = player - 1; if (currentState.matchOver) return; // ---- TIEBREAK ---- if (currentState.tiebreak) { currentState.tiebreakPoints[idx]++; const tp1 = currentState.tiebreakPoints[0]; const tp2 = currentState.tiebreakPoints[1]; // Aggiorna server per il punto successivo nel tiebreak updateTiebreakServer(currentState); // Vinta se โ‰ฅ7 punti e 2 di scarto if ((tp1 >= 7 || tp2 >= 7) && Math.abs(tp1 - tp2) >= 2) { // Ha vinto il tiebreak โ†’ set 7-6 const won = tp1 > tp2 ? 0 : 1; currentState.sets[currentState.currentSet][won] = 7; currentState.sets[currentState.currentSet][1 - won] = 6; currentState.tiebreak = false; currentState.tiebreakPoints = [0, 0]; currentState.games = [0, 0]; // Controlla match vinto let p1Sets = 0, p2Sets = 0; for (let i = 0; i < 3; i++) { if (currentState.sets[i][0] > currentState.sets[i][1]) p1Sets++; else if (currentState.sets[i][1] > currentState.sets[i][0]) p2Sets++; } if (p1Sets >= 2 || p2Sets >= 2) { currentState.matchOver = true; } else { currentState.currentSet++; } currentState.server = currentState.server === 1 ? 2 : 1; } return; } // ---- GIOCO NORMALE ---- if (currentState.deuce) { if (currentState.advantage === player) { currentState.points = [0, 0]; currentState.deuce = false; currentState.advantage = 0; addGame(player); } else if (currentState.advantage === 0) { currentState.advantage = player; } else { currentState.advantage = 0; } return; } currentState.points[idx]++; const p1 = currentState.points[0]; const p2 = currentState.points[1]; if (p1 >= 3 && p2 >= 3 && p1 === p2) { currentState.deuce = true; currentState.advantage = 0; return; } if ((p1 >= 4 || p2 >= 4) && Math.abs(p1 - p2) >= 2) { currentState.points = [0, 0]; addGame(player); return; } } function addGame(player) { const idx = player - 1; currentState.games[idx]++; const [g1, g2] = currentState.games; const set = currentState.currentSet; // 6-6 โ†’ si entra in tiebreak if (g1 === 6 && g2 === 6) { currentState.tiebreak = true; currentState.tiebreakPoints = [0, 0]; currentState.points = [0, 0]; currentState.deuce = false; currentState.advantage = 0; // Il primo a servire nel tiebreak รจ il giocatore che NON ha servito l'ultimo game currentState.tiebreakFirstServer = currentState.server === 1 ? 2 : 1; currentState.server = currentState.tiebreakFirstServer; return; } if ((g1 >= 6 || g2 >= 6) && Math.abs(g1 - g2) >= 2) { currentState.sets[set][0] = g1; currentState.sets[set][1] = g2; currentState.games = [0, 0]; let p1Sets = 0, p2Sets = 0; for (let i = 0; i < 3; i++) { if (currentState.sets[i][0] > currentState.sets[i][1]) p1Sets++; else if (currentState.sets[i][1] > currentState.sets[i][0]) p2Sets++; } if (p1Sets >= 2 || p2Sets >= 2) { currentState.matchOver = true; } else { currentState.currentSet++; } } else { currentState.sets[set][0] = g1; currentState.sets[set][1] = g2; } currentState.server = currentState.server === 1 ? 2 : 1; } /** * Determina il servitore nel tiebreak. * Nel tiebreak: * - Punto 1: servito dal giocatore designato (tiebreakFirstServer) * - Punto 2: servito dall'altro giocatore * - Dal punto 3 in poi: cambio servizio ogni 2 punti */ function updateTiebreakServer(state) { const total = state.tiebreakPoints[0] + state.tiebreakPoints[1]; const first = state.tiebreakFirstServer; if (total === 0) { state.server = first; } else if (total === 1) { // Dopo il primo punto, serve l'altro giocatore state.server = first === 1 ? 2 : 1; } else { // Gruppi da 2: alternati const groupIndex = Math.floor((total - 1) / 2); if (groupIndex % 2 === 0) { // Gruppi pari (0, 2, 4...): serve l'altro giocatore state.server = first === 1 ? 2 : 1; } else { // Gruppi dispari (1, 3, 5...): serve il primo giocatore state.server = first; } } } // ---- Avvio ---- server.listen(PORT, () => { console.log(` โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— โ•‘ ๐ŸŽพ Tennis Roots Server โ•‘ โ•‘ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ•‘ โ•‘ HTTP : http://localhost:${PORT} โ•‘ โ•‘ WS : ws://localhost:${PORT}/ws โ•‘ โ•‘ โ•‘ โ•‘ Controller : http://localhost:${PORT}/ โ•‘ โ•‘ Overlay : http://localhost:${PORT}/overlay/ โ•‘ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• `); });