refactor(server): separa la logica WebSocket e centralizza le utility di avvio
Estrae la gestione dei messaggi WebSocket in un modulo dedicato. Rende server.js più snello e focalizzato su bootstrap HTTP/WS. Introduce utility per stampa URL di accesso e discovery IP di rete. Mantiene la logica di stato partita condivisa in gameState.js.
This commit is contained in:
221
server.js
221
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)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
45
src/server-utils.js
Normal file
45
src/server-utils.js
Normal file
@@ -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()
|
||||
}
|
||||
171
src/websocket-handler.js
Normal file
171
src/websocket-handler.js
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user