diff --git a/server.js b/server.js index 75cb5c7..0f8363e 100644 --- a/server.js +++ b/server.js @@ -3,233 +3,32 @@ import express from 'express' import { WebSocketServer } from 'ws' import { fileURLToPath } from 'url' import { dirname, join } from 'path' +import { setupWebSocketHandler } from './src/websocket-handler.js' +import { printServerInfo } from './src/server-utils.js' -// Import shared game logic -// We need to read it as a dynamic import since it uses ES modules const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -// Inline the game state logic for the server (avoid complex ESM import from src/) -function createInitialState() { - return { - order: true, - visuForm: false, - visuStriscia: true, - modalitaPartita: "3/5", - sp: { - striscia: { home: [0], guest: [0] }, - servHome: true, - punt: { home: 0, guest: 0 }, - set: { home: 0, guest: 0 }, - nomi: { home: "Antoniana", guest: "Guest" }, - form: { - home: ["1", "2", "3", "4", "5", "6"], - guest: ["1", "2", "3", "4", "5", "6"], - }, - storicoServizio: [], - }, - } -} - -function checkVittoria(state) { - const puntHome = state.sp.punt.home - const puntGuest = state.sp.punt.guest - const setHome = state.sp.set.home - const setGuest = state.sp.set.guest - const totSet = setHome + setGuest - let isSetDecisivo = false - if (state.modalitaPartita === "2/3") { - isSetDecisivo = totSet >= 2 - } else { - isSetDecisivo = totSet >= 4 - } - const punteggioVittoria = isSetDecisivo ? 15 : 25 - if (puntHome >= punteggioVittoria && puntHome - puntGuest >= 2) return true - if (puntGuest >= punteggioVittoria && puntGuest - puntHome >= 2) return true - return false -} - -function applyAction(state, action) { - const s = JSON.parse(JSON.stringify(state)) - switch (action.type) { - case "incPunt": { - const team = action.team - if (checkVittoria(s)) break - s.sp.storicoServizio.push({ - servHome: s.sp.servHome, - cambioPalla: (team === "home" && !s.sp.servHome) || (team === "guest" && s.sp.servHome), - }) - s.sp.punt[team]++ - if (team === "home") { - s.sp.striscia.home.push(s.sp.punt.home) - s.sp.striscia.guest.push(" ") - } else { - s.sp.striscia.guest.push(s.sp.punt.guest) - s.sp.striscia.home.push(" ") - } - const cambioPalla = (team === "home" && !s.sp.servHome) || (team === "guest" && s.sp.servHome) - if (cambioPalla) { - s.sp.form[team].push(s.sp.form[team].shift()) - } - s.sp.servHome = team === "home" - break - } - case "decPunt": { - if (s.sp.striscia.home.length > 1 && s.sp.storicoServizio.length > 0) { - const tmpHome = s.sp.striscia.home.pop() - s.sp.striscia.guest.pop() - const statoServizio = s.sp.storicoServizio.pop() - if (tmpHome === " ") { - s.sp.punt.guest-- - if (statoServizio.cambioPalla) { - s.sp.form.guest.unshift(s.sp.form.guest.pop()) - } - } else { - s.sp.punt.home-- - if (statoServizio.cambioPalla) { - s.sp.form.home.unshift(s.sp.form.home.pop()) - } - } - s.sp.servHome = statoServizio.servHome - } - break - } - case "incSet": { - const team = action.team - if (s.sp.set[team] === 2) { s.sp.set[team] = 0 } else { s.sp.set[team]++ } - break - } - case "cambiaPalla": { - if (s.sp.punt.home === 0 && s.sp.punt.guest === 0) { - s.sp.servHome = !s.sp.servHome - } - break - } - case "resetta": { - s.visuForm = false - s.sp.punt.home = 0 - s.sp.punt.guest = 0 - s.sp.form = { - home: ["1", "2", "3", "4", "5", "6"], - guest: ["1", "2", "3", "4", "5", "6"], - } - s.sp.striscia = { home: [0], guest: [0] } - s.sp.storicoServizio = [] - break - } - case "toggleFormazione": { s.visuForm = !s.visuForm; break } - case "toggleStriscia": { s.visuStriscia = !s.visuStriscia; break } - case "toggleOrder": { s.order = !s.order; break } - case "setNomi": { - if (action.home !== undefined) s.sp.nomi.home = action.home - if (action.guest !== undefined) s.sp.nomi.guest = action.guest - break - } - case "setModalita": { s.modalitaPartita = action.modalita; break } - case "setFormazione": { - if (action.team && action.form) { - s.sp.form[action.team] = [...action.form] - } - break - } - case "confermaCambi": { - const team = action.team - const cambi = action.cambi || [] - const form = s.sp.form[team].map((val) => String(val).trim()) - const formAggiornata = [...form] - let valid = true - for (const cambio of cambi) { - const cin = (cambio.in || "").trim() - const cout = (cambio.out || "").trim() - if (!cin || !cout) continue - if (!/^\d+$/.test(cin) || !/^\d+$/.test(cout)) { valid = false; break } - if (cin === cout) { valid = false; break } - if (formAggiornata.includes(cin)) { valid = false; break } - if (!formAggiornata.includes(cout)) { valid = false; break } - const idx = formAggiornata.findIndex((val) => String(val).trim() === cout) - if (idx !== -1) { formAggiornata.splice(idx, 1, cin) } - } - if (valid) { s.sp.form[team] = formAggiornata } - break - } - default: break - } - return s -} - -// ——— Server Setup ——— +// --- Configurazione del server --- const app = express() const PORT = process.env.PORT || 3000 -// Serve the Vite build output +// Espone i file generati dalla build di Vite. app.use(express.static(join(__dirname, 'dist'))) -// SPA fallback: serve index.html for all non-file routes -app.get('/{*splat}', (req, res) => { +// Fallback per SPA: restituisce `index.html` per tutte le route che non puntano a file statici. +app.get('/{*splat}', (_req, res) => { res.sendFile(join(__dirname, 'dist', 'index.html')) }) const server = createServer(app) -// WebSocket server +// Inizializza il server WebSocket con la logica di gioco. const wss = new WebSocketServer({ server }) +setupWebSocketHandler(wss) -// Global game state -let gameState = createInitialState() - -// Track client roles -const clients = new Map() // ws -> { role: 'display' | 'controller' } - -wss.on('connection', (ws) => { - console.log('New WebSocket connection') - - ws.on('message', (data) => { - try { - const msg = JSON.parse(data.toString()) - - if (msg.type === 'register') { - clients.set(ws, { role: msg.role || 'display' }) - console.log(`Client registered as: ${msg.role || 'display'}`) - // Send current state immediately - ws.send(JSON.stringify({ type: 'state', state: gameState })) - return - } - - if (msg.type === 'action') { - // Only controllers can send actions - const client = clients.get(ws) - if (!client || client.role !== 'controller') { - console.log('Action rejected: not a controller') - return - } - - // Apply the action to game state - gameState = applyAction(gameState, msg.action) - - // Broadcast new state to ALL connected clients - const stateMsg = JSON.stringify({ type: 'state', state: gameState }) - wss.clients.forEach((c) => { - if (c.readyState === 1) { // WebSocket.OPEN - c.send(stateMsg) - } - }) - } - } catch (err) { - console.error('Error processing message:', err) - } - }) - - ws.on('close', () => { - clients.delete(ws) - console.log('Client disconnected') - }) -}) - +// Avvia il server HTTP. server.listen(PORT, '0.0.0.0', () => { - console.log(`\n🏐 Segnapunti Server running on:`) - console.log(` Display: http://localhost:${PORT}/`) - console.log(` Controller: http://localhost:${PORT}/controller`) - console.log(`\n Per accedere da altri dispositivi sulla rete locale,`) - console.log(` usa l'IP di questo computer, es: http://192.168.1.x:${PORT}/controller\n`) + printServerInfo(PORT) }) diff --git a/src/gameState.js b/src/gameState.js index 6486b11..66b980f 100644 --- a/src/gameState.js +++ b/src/gameState.js @@ -1,6 +1,6 @@ /** - * Shared game logic for segnapunti. - * Used by both the WebSocket server and the client-side for local preview. + * Logica di gioco condivisa per il segnapunti. + * Utilizzata sia dal server WebSocket sia dal client per l'anteprima locale. */ export function createInitialState() { @@ -51,8 +51,8 @@ export function checkVittoria(state) { } export function applyAction(state, action) { - // Deep-clone to avoid mutation issues (server-side) - // Returns new state + // Esegue un deep clone per evitare mutazioni indesiderate dello stato lato server. + // Restituisce sempre un nuovo oggetto di stato. const s = JSON.parse(JSON.stringify(state)) switch (action.type) { diff --git a/src/server-utils.js b/src/server-utils.js new file mode 100644 index 0000000..356a06f --- /dev/null +++ b/src/server-utils.js @@ -0,0 +1,45 @@ +import { networkInterfaces } from 'os' + +/** + * Restituisce gli indirizzi IP di rete del sistema, escludendo loopback e bridge Docker. + * @returns {string[]} Elenco degli indirizzi IP disponibili. + */ +export function getNetworkIPs() { + const nets = networkInterfaces() + const networkIPs = [] + + for (const name of Object.keys(nets)) { + for (const net of nets[name]) { + // Esclude loopback (127.0.0.1), indirizzi non IPv4 e bridge Docker (172.17.x.x, 172.18.x.x). + if (net.family === 'IPv4' && + !net.internal && + !net.address.startsWith('172.17.') && + !net.address.startsWith('172.18.')) { + networkIPs.push(net.address) + } + } + } + + return networkIPs +} + +/** + * Stampa il riepilogo di avvio del server con gli URL di accesso. + * @param {number} port - Porta sulla quale il server e in ascolto. + */ +export function printServerInfo(port = 5173) { + const networkIPs = getNetworkIPs() + + console.log(`\nSegnapunti Server`) + console.log(` Display: http://localhost:${port}/`) + console.log(` Controller: http://localhost:${port}/controller`) + + if (networkIPs.length > 0) { + console.log(`\n Da dispositivi remoti:`) + networkIPs.forEach(ip => { + console.log(` http://${ip}:${port}/controller`) + }) + } + + console.log() +} diff --git a/src/websocket-handler.js b/src/websocket-handler.js new file mode 100644 index 0000000..5941bb8 --- /dev/null +++ b/src/websocket-handler.js @@ -0,0 +1,171 @@ +import { createInitialState, applyAction } from './gameState.js' + +/** + * Crea e configura il server WebSocket per la gestione dello stato di gioco. + * @param {WebSocketServer} wss - Istanza del server WebSocket. + * @returns {Object} Oggetto con metodi di gestione dello stato. + */ +export function setupWebSocketHandler(wss) { + // Stato globale della partita. + let gameState = createInitialState() + + // Mappa dei ruoli associati ai client connessi. + const clients = new Map() // ws -> { role: 'display' | 'controller' } + + /** + * Gestisce i messaggi in arrivo dal client + */ + function handleMessage(ws, data) { + try { + // Converte il payload in stringa in modo sicuro, anche se arriva come Buffer. + const dataStr = typeof data === 'string' ? data : data.toString('utf8') + const msg = JSON.parse(dataStr) + + if (msg.type === 'register') { + return handleRegister(ws, msg) + } + + if (msg.type === 'action') { + return handleAction(ws, msg) + } + } catch (err) { + console.error('Error processing message:', err, 'data:', data) + // Invia l'errore solo se la connessione e ancora aperta. + if (ws.readyState === 1) { // Stato WebSocket.OPEN + try { + sendError(ws, 'Invalid message format') + } catch (sendErr) { + console.error('Error sending error message:', sendErr) + } + } + } + } + + /** + * Gestisce la registrazione di un client (display o controller) + */ + function handleRegister(ws, msg) { + const role = msg.role || 'display' + + // Valida il ruolo dichiarato dal client. + if (!['display', 'controller'].includes(role)) { + sendError(ws, 'Invalid role') + return + } + + clients.set(ws, { role }) + console.log(`[WebSocket] Client registered as: ${role} (total clients: ${clients.size})`) + + // Invia subito lo stato corrente, se la connessione e aperta. + if (ws.readyState === 1) { // Stato WebSocket.OPEN + try { + ws.send(JSON.stringify({ type: 'state', state: gameState })) + } catch (err) { + console.error('Error sending initial state:', err) + } + } + } + + /** + * Gestisce un'azione di gioco dal controller + */ + function handleAction(ws, msg) { + // Solo i client controller possono inviare azioni. + const client = clients.get(ws) + if (!client || client.role !== 'controller') { + sendError(ws, 'Only controllers can send actions') + return + } + + // Verifica il formato dell'azione ricevuta. + if (!msg.action || !msg.action.type) { + sendError(ws, 'Invalid action format') + return + } + + // Applica l'azione allo stato della partita. + const previousState = gameState + try { + gameState = applyAction(gameState, msg.action) + } catch (err) { + console.error('Error applying action:', err) + sendError(ws, 'Failed to apply action') + gameState = previousState + return + } + + // Propaga il nuovo stato a tutti i client connessi. + broadcastState() + } + + /** + * Invia un messaggio di errore al client + */ + function sendError(ws, message) { + if (ws.readyState === 1) { // Stato WebSocket.OPEN + try { + ws.send(JSON.stringify({ type: 'error', message })) + } catch (err) { + console.error('Failed to send error message:', err) + } + } + } + + /** + * Invia lo stato corrente a tutti i client connessi. + */ + function broadcastState() { + const stateMsg = JSON.stringify({ type: 'state', state: gameState }) + wss.clients.forEach((client) => { + if (client.readyState === 1) { // Stato WebSocket.OPEN + client.send(stateMsg) + } + }) + } + + /** + * Gestisce la chiusura della connessione + */ + function handleClose(ws) { + const client = clients.get(ws) + const role = client?.role || 'unknown' + console.log(`[WebSocket] Client disconnected (role: ${role})`) + clients.delete(ws) + } + + /** + * Gestisce gli errori WebSocket + */ + function handleError(err, ws) { + console.error('WebSocket error:', err) + + // In caso di frame non validi, chiude forzatamente la connessione. + if (err.code === 'WS_ERR_INVALID_CLOSE_CODE' || err.code === 'WS_ERR_INVALID_UTF8') { + try { + if (ws && ws.readyState === 1) { // Stato WebSocket.OPEN + ws.terminate() // Chiusura forzata senza handshake di chiusura. + } + } catch (closeErr) { + console.error('Error closing connection:', closeErr) + } + } + } + + // Registra gli handler per ogni nuova connessione. + wss.on('connection', (ws) => { + // Imposta il tipo binario per ridurre i problemi di codifica. + ws.binaryType = 'arraybuffer' + + ws.on('message', (data) => handleMessage(ws, data)) + ws.on('close', () => handleClose(ws)) + ws.on('error', (err) => handleError(err, ws)) + }) + + // Espone un'API pubblica per controllo esterno, se necessario. + return { + getState: () => gameState, + setState: (newState) => { gameState = newState }, + broadcastState, + getClients: () => clients, + } +}