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 { WebSocketServer } from 'ws'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { dirname, join } from 'path'
|
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 __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
// Inline the game state logic for the server (avoid complex ESM import from src/)
|
// --- Configurazione del server ---
|
||||||
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 ———
|
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT || 3000
|
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')))
|
app.use(express.static(join(__dirname, 'dist')))
|
||||||
|
|
||||||
// SPA fallback: serve index.html for all non-file routes
|
// Fallback per SPA: restituisce `index.html` per tutte le route che non puntano a file statici.
|
||||||
app.get('/{*splat}', (req, res) => {
|
app.get('/{*splat}', (_req, res) => {
|
||||||
res.sendFile(join(__dirname, 'dist', 'index.html'))
|
res.sendFile(join(__dirname, 'dist', 'index.html'))
|
||||||
})
|
})
|
||||||
|
|
||||||
const server = createServer(app)
|
const server = createServer(app)
|
||||||
|
|
||||||
// WebSocket server
|
// Inizializza il server WebSocket con la logica di gioco.
|
||||||
const wss = new WebSocketServer({ server })
|
const wss = new WebSocketServer({ server })
|
||||||
|
setupWebSocketHandler(wss)
|
||||||
|
|
||||||
// Global game state
|
// Avvia il server HTTP.
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`\n🏐 Segnapunti Server running on:`)
|
printServerInfo(PORT)
|
||||||
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`)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Shared game logic for segnapunti.
|
* Logica di gioco condivisa per il segnapunti.
|
||||||
* Used by both the WebSocket server and the client-side for local preview.
|
* Utilizzata sia dal server WebSocket sia dal client per l'anteprima locale.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
@@ -51,8 +51,8 @@ export function checkVittoria(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function applyAction(state, action) {
|
export function applyAction(state, action) {
|
||||||
// Deep-clone to avoid mutation issues (server-side)
|
// Esegue un deep clone per evitare mutazioni indesiderate dello stato lato server.
|
||||||
// Returns new state
|
// Restituisce sempre un nuovo oggetto di stato.
|
||||||
const s = JSON.parse(JSON.stringify(state))
|
const s = JSON.parse(JSON.stringify(state))
|
||||||
|
|
||||||
switch (action.type) {
|
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