Compare commits
11 Commits
v2.0.0
...
d322809682
| Author | SHA1 | Date | |
|---|---|---|---|
| d322809682 | |||
| 6b37fd299f | |||
| 87ce0e26b8 | |||
| 38703116ff | |||
| 8e2f3d759d | |||
| 6aeeb47f16 | |||
| a094110be3 | |||
| 756f78358c | |||
| fb4177056f | |||
| 303c548ab8 | |||
| 43e49c4c66 |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"claude-md-management@claude-plugins-official": true,
|
||||||
|
"code-simplifier@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,28 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Purpose
|
## Scopo del progetto
|
||||||
|
|
||||||
**Segnapunti Anto** is a real-time volleyball scoreboard PWA. An Express/WebSocket server hosts the game state; two separate web interfaces — a **display** (public scoreboard) and a **controller** (operator panel) — stay in sync via WebSocket.
|
**Segnapunti Anto** è una PWA per il segnapunti pallavolo in tempo reale. Un server Express/WebSocket gestisce lo stato di gioco; due interfacce Vue separate — **display** (tabellone pubblico) e **controller** (pannello operatore) — si sincronizzano via WebSocket.
|
||||||
|
|
||||||
## Commands
|
## Comandi
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Vite dev server — display: :5173/display, controller: :5173/controller
|
npm run dev # Vite dev server — display: :5173/display, controller: :5173/controller
|
||||||
npm run serve # Build + run production — display: :3000/display, controller: :3000/controller
|
npm run serve # Build + avvio produzione — display: :3000/display, controller: :3000/controller
|
||||||
|
|
||||||
npm run test # Vitest watch mode
|
npm run test # Vitest in modalità watch
|
||||||
npm run test:all # All Vitest suites once (unit, integration, component, stress)
|
npm run test:all # Tutte le suite Vitest in una sola esecuzione
|
||||||
npm run test:unit # Unit + integration only
|
npm run test:unit # Solo unit + integration
|
||||||
npm run test:component # Vue component tests
|
npm run test:component # Test componenti Vue (happy-dom)
|
||||||
npm run test:stress # Load tests (50+ concurrent clients)
|
npm run test:stress # Load test (50+ client concorrenti)
|
||||||
npm run test:e2e # Playwright E2E (requires servers to be running via npm run serve)
|
npm run test:e2e # Playwright E2E (richiede npm run serve attivo)
|
||||||
npm run test:e2e:ui # Playwright with interactive UI
|
npm run test:e2e:ui # Playwright con UI interattiva
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
Per eseguire un singolo file di test: `npx vitest run tests/unit/gameState.test.js`
|
||||||
|
|
||||||
|
## Architettura
|
||||||
|
|
||||||
```
|
```
|
||||||
Controller (Vue) ──WebSocket──┐
|
Controller (Vue) ──WebSocket──┐
|
||||||
@@ -30,33 +32,51 @@ Display (Vue) ──WebSocket──┤── websocket-handler.js ── gam
|
|||||||
│ └── persist.js ── .segnapunti/state.json
|
│ └── persist.js ── .segnapunti/state.json
|
||||||
```
|
```
|
||||||
|
|
||||||
All game logic lives in `src/gameState.js` as three pure functions:
|
**Tutta la logica di gioco** è in `src/gameState.js` come tre funzioni pure esportate:
|
||||||
- `createInitialState()` — returns the initial state
|
- `createInitialState()` — restituisce lo stato iniziale
|
||||||
- `applyAction(state, action)` — immutable reducer (deep-clones via `structuredClone`)
|
- `applyAction(state, action)` — reducer immutabile (deep-clone via `structuredClone`)
|
||||||
- `checkVittoria(state)` — volleyball win conditions (25-point sets with 2-point margin, 15-point final set)
|
- `checkVittoria(state)` — condizioni di vittoria set (25 punti, vantaggio di 2; 15 punti nel set decisivo)
|
||||||
|
|
||||||
`src/websocket-handler.js` receives actions, validates that the sender is a registered controller (not just a display), calls `applyAction`, broadcasts the new state to all clients, then calls `onStateChange` to persist state to disk.
|
`src/websocket-handler.js` riceve i messaggi WebSocket, valida che il mittente sia un `controller` (non un `display`), chiama `applyAction`, fa il broadcast del nuovo stato a tutti i client, poi invoca `onStateChange` per persistere su disco.
|
||||||
|
|
||||||
`server.js` (Express) serves both interfaces on port 3000: `/display` → `dist/index.html`, `/controller` → `dist/controller.html`. Single WebSocket endpoint at `/ws`.
|
`server.js` (Express) serve entrambe le interfacce sulla porta 3000: `/display` → `dist/index.html`, `/controller` → `dist/controller.html`. Singolo endpoint WebSocket su `/ws`.
|
||||||
|
|
||||||
In development, `vite-plugin-websocket.js` embeds the WebSocket server inside the Vite dev server with URL rewrite middleware for `/display` and `/controller`.
|
In sviluppo, `vite-plugin-websocket.js` incorpora il server WebSocket dentro il dev server Vite con middleware di URL rewrite per `/display` e `/controller`.
|
||||||
|
|
||||||
## Key Design Constraints
|
## Vincoli di design
|
||||||
|
|
||||||
- **All game rules on the server** — clients are pure UI; the server is the source of truth.
|
- **Tutta la logica sul server** — i client sono pura UI; il server è l'unica source of truth.
|
||||||
- **Role-based WebSocket** — clients register as `display` or `controller`; only controllers may send actions.
|
- **WebSocket role-based** — i client si registrano come `display` o `controller`; solo i controller possono inviare azioni.
|
||||||
- **Immutable state** — `applyAction` never mutates; always returns a new state object.
|
- **Stato immutabile** — `applyAction` non muta mai lo stato, restituisce sempre un nuovo oggetto.
|
||||||
- **Single-controller intent** — the design targets one active controller and one display; no conflict resolution exists for simultaneous controllers.
|
- **Un solo controller** — il design prevede un controller e un display attivi; non esiste conflict resolution per controller simultanei.
|
||||||
- **Persistent state** — `src/persist.js` saves state to `.segnapunti/state.json` after every action and loads it on server startup.
|
- **Stato persistente** — `src/persist.js` salva lo stato in `.segnapunti/state.json` dopo ogni azione e lo carica all'avvio del server.
|
||||||
|
|
||||||
## Test Layout
|
## Layout dei test
|
||||||
|
|
||||||
| Suite | Path | Runner |
|
| Suite | Percorso | Runner |
|
||||||
|-------|------|--------|
|
|-------|----------|--------|
|
||||||
| Unit | `tests/unit/` | Vitest + Node |
|
| Unit | `tests/unit/` | Vitest + Node |
|
||||||
| Integration | `tests/integration/` | Vitest + Node |
|
| Integration | `tests/integration/` | Vitest + Node |
|
||||||
| Component | `tests/component/` | Vitest + Happy-DOM |
|
| Component | `tests/component/` | Vitest + Happy-DOM |
|
||||||
| Stress | `tests/stress/` | Vitest + Node |
|
| Stress | `tests/stress/` | Vitest + Node |
|
||||||
| E2E | `tests/e2e/` | Playwright (Chromium, Firefox, Mobile Chrome) |
|
| E2E | `tests/e2e/` | Playwright (Chromium, Firefox, Mobile Chrome) |
|
||||||
|
|
||||||
E2E tests run serially (`workers: 1`) to avoid WebSocket state races. Run `npm run serve` before `npm run test:e2e`.
|
I test E2E girano in serie (`workers: 1`) per evitare race condition sullo stato WebSocket. Eseguire `npm run serve` prima di `npm run test:e2e`.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Il progetto si distribuisce tramite Docker. L'immagine è pubblicata sul registry Gitea self-hosted (`santantonio.sytes.net`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # Avvia con immagine dal registry privato (porta 3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
Il volume `./.segnapunti` persiste lo stato tra i riavvii del container.
|
||||||
|
|
||||||
|
## Istruzioni operative
|
||||||
|
|
||||||
|
- Per ogni nuova funzionalità: analizza come si inserisce nel flusso `action → applyAction → broadcast`, poi decidi se aggiungere un nuovo tipo di action o estendere uno esistente.
|
||||||
|
- Qualsiasi modifica alle regole di gioco va fatta esclusivamente in `src/gameState.js`.
|
||||||
|
- Qualsiasi modifica al protocollo WebSocket va fatta in `src/websocket-handler.js`.
|
||||||
|
- Aggiungere test unit in `tests/unit/gameState.test.js` per ogni nuovo action type o regola di gioco.
|
||||||
|
- Il codice deve essere scritto in italiano per commenti e nomi di variabili di dominio (es. `servHome`, `striscia`, `nomi`), ma in inglese per nomi tecnici standard (`state`, `action`, `handler`).
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ Segnapunti digitale in tempo reale per partite di pallavolo. Un server centrale
|
|||||||
- [Architettura](#architettura)
|
- [Architettura](#architettura)
|
||||||
- [Guida utente](#guida-utente)
|
- [Guida utente](#guida-utente)
|
||||||
- [Funzionalità](#funzionalità)
|
- [Funzionalità](#funzionalità)
|
||||||
- [Shortcuts tastiera](#shortcuts-tastiera)
|
|
||||||
- [Deploy con Docker](#deploy-con-docker)
|
- [Deploy con Docker](#deploy-con-docker)
|
||||||
- [Sviluppo](#sviluppo)
|
- [Sviluppo](#sviluppo)
|
||||||
- [Test](#test)
|
- [Test](#test)
|
||||||
@@ -111,25 +110,6 @@ http://<IP-del-server>:3000/controller
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Shortcuts tastiera
|
|
||||||
|
|
||||||
> Valide sul controller da browser desktop.
|
|
||||||
|
|
||||||
| Tasto | Azione |
|
|
||||||
|---|---|
|
|
||||||
| `Ctrl + ↑` | Punto casa |
|
|
||||||
| `Ctrl + ↓` | Annulla ultimo punto |
|
|
||||||
| `Shift + ↑` | Punto ospite |
|
|
||||||
| `Ctrl + ←` | Cambia servizio (solo a 0-0) |
|
|
||||||
| `Ctrl + M` | Apri configurazione |
|
|
||||||
| `Ctrl + C` | Cambi squadra casa |
|
|
||||||
| `Shift + C` | Cambi squadra ospite |
|
|
||||||
| `Ctrl + Z` | Toggle punteggio / formazioni |
|
|
||||||
| `Ctrl + S` | Annuncio vocale punteggio |
|
|
||||||
| `Ctrl + B` | Mostra/nascondi barra pulsanti |
|
|
||||||
| `Ctrl + F` | Fullscreen |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deploy con Docker
|
## Deploy con Docker
|
||||||
|
|
||||||
|
|||||||
+2
-5
@@ -1,12 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
segnapunti:
|
segnapunti:
|
||||||
image: santantonio.sytes.net/attilio/segnapunti:1.0.0
|
image: santantonio.sytes.net/attilio/segnapunti:2.0.0
|
||||||
container_name: segnapunti
|
container_name: segnapunti
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- segnapunti-state:/app/.segnapunti
|
- ./.segnapunti:/app/.segnapunti
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
segnapunti-state:
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite build && node server.js",
|
"serve": "vite build && node server.js",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
<div class="score-preview">
|
<div class="score-preview">
|
||||||
<div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })">
|
<div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })">
|
||||||
<div class="team-name">{{ state.sp.nomi.home }}</div>
|
<div class="team-name">{{ state.sp.nomi.home }}</div>
|
||||||
<div class="team-pts">{{ state.sp.punt.home }}</div>
|
<div class="team-pts">{{ punt.home }}</div>
|
||||||
<div class="team-set">SET {{ state.sp.set.home }}</div>
|
<div class="team-set">SET {{ set.home }}</div>
|
||||||
<img v-show="state.sp.servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
|
<img v-show="servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="team-score guest-bg" @click="sendAction({ type: 'incPunt', team: 'guest' })">
|
<div class="team-score guest-bg" @click="sendAction({ type: 'incPunt', team: 'guest' })">
|
||||||
<div class="team-name">{{ state.sp.nomi.guest }}</div>
|
<div class="team-name">{{ state.sp.nomi.guest }}</div>
|
||||||
<div class="team-pts">{{ state.sp.punt.guest }}</div>
|
<div class="team-pts">{{ punt.guest }}</div>
|
||||||
<div class="team-set">SET {{ state.sp.set.guest }}</div>
|
<div class="team-set">SET {{ set.guest }}</div>
|
||||||
<img v-show="!state.sp.servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
|
<img v-show="!servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,15 +78,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Finestra set vinto -->
|
<!-- Finestra set vinto / partita finita -->
|
||||||
<div class="overlay" v-if="showSetVinto">
|
<div class="overlay" v-if="showSetVinto">
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
<div class="dialog-title">SET VINTO</div>
|
<div class="dialog-title">{{ isPartitaFinita ? 'PARTITA FINITA' : 'SET VINTO' }}</div>
|
||||||
<div class="dialog-winner">{{ state.sp.nomi[setVintoTeam] }}</div>
|
<div class="dialog-winner">{{ state.sp.nomi[setVintoTeam] }}</div>
|
||||||
<div class="dialog-subtitle">Configura le formazioni per il prossimo set</div>
|
<div class="dialog-subtitle" v-if="!isPartitaFinita">Configura le formazioni per il prossimo set</div>
|
||||||
<div class="dialog-buttons">
|
<div class="dialog-buttons">
|
||||||
<button class="btn btn-cancel" @click="undoUltimoPoint()">INDIETRO</button>
|
<button class="btn btn-cancel" @click="undoUltimoPoint()">INDIETRO</button>
|
||||||
<button class="btn btn-confirm" @click="doNuovoSet()">VAI AL SET SUCCESSIVO</button>
|
<button v-if="isPartitaFinita" class="btn btn-confirm" @click="showSetVinto = false">CHIUDI</button>
|
||||||
|
<button v-else class="btn btn-confirm" @click="doNuovoSet()">VAI AL SET SUCCESSIVO</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,6 +113,8 @@
|
|||||||
@click="configData.modalita = '2/3'">2/3</button>
|
@click="configData.modalita = '2/3'">2/3</button>
|
||||||
<button :class="['btn', 'btn-mode', configData.modalita === '3/5' ? 'active' : '']"
|
<button :class="['btn', 'btn-mode', configData.modalita === '3/5' ? 'active' : '']"
|
||||||
@click="configData.modalita = '3/5'">3/5</button>
|
@click="configData.modalita = '3/5'">3/5</button>
|
||||||
|
<button :class="['btn', 'btn-mode', configData.modalita === 'amichevole' ? 'active' : '']"
|
||||||
|
@click="configData.modalita = 'amichevole'">Amichevole</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,16 +192,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { createWsMixin } from '../wsMixin.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ControllerPage",
|
name: "ControllerPage",
|
||||||
|
mixins: [createWsMixin('controller')],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
ws: null,
|
|
||||||
wsConnected: false,
|
|
||||||
isConnecting: false,
|
|
||||||
reconnectTimeout: null,
|
|
||||||
reconnectAttempts: 0,
|
|
||||||
maxReconnectDelay: 30000,
|
|
||||||
confirmReset: false,
|
confirmReset: false,
|
||||||
showSetVinto: false,
|
showSetVinto: false,
|
||||||
setVintoTeam: null,
|
setVintoTeam: null,
|
||||||
@@ -206,62 +206,44 @@ export default {
|
|||||||
showCambiTeam: false,
|
showCambiTeam: false,
|
||||||
showCambi: false,
|
showCambi: false,
|
||||||
cambiTeam: "home",
|
cambiTeam: "home",
|
||||||
cambiData: [
|
cambiData: [{ in: "", out: "" }, { in: "", out: "" }],
|
||||||
{ in: "", out: "" },
|
|
||||||
{ in: "", out: "" },
|
|
||||||
],
|
|
||||||
cambiError: "",
|
cambiError: "",
|
||||||
configData: {
|
configData: {
|
||||||
nomeHome: "",
|
nomeHome: "", nomeGuest: "", modalita: "3/5",
|
||||||
nomeGuest: "",
|
|
||||||
modalita: "3/5",
|
|
||||||
formHome: ["1", "2", "3", "4", "5", "6"],
|
formHome: ["1", "2", "3", "4", "5", "6"],
|
||||||
formGuest: ["1", "2", "3", "4", "5", "6"],
|
formGuest: ["1", "2", "3", "4", "5", "6"],
|
||||||
},
|
},
|
||||||
state: {
|
|
||||||
order: true,
|
|
||||||
visuForm: false,
|
|
||||||
visuStriscia: true,
|
|
||||||
modalitaPartita: "3/5",
|
|
||||||
sp: {
|
|
||||||
striscia: [{ serv: 'home', r: [] }],
|
|
||||||
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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isPunteggioZeroZero() {
|
isPunteggioZeroZero() {
|
||||||
return this.state.sp.punt.home === 0 && this.state.sp.punt.guest === 0
|
return this.state.sp.striscia.at(-1).ris === ''
|
||||||
},
|
},
|
||||||
squadraVincente() {
|
squadraVincente() {
|
||||||
const { home, guest } = this.state.sp.punt
|
const { home, guest } = this.punt
|
||||||
const totSet = this.state.sp.set.home + this.state.sp.set.guest
|
const sv = this.set
|
||||||
|
const totSet = sv.home + sv.guest
|
||||||
const isSetDecisivo = this.state.modalitaPartita === '2/3' ? totSet >= 2 : totSet >= 4
|
const isSetDecisivo = this.state.modalitaPartita === '2/3' ? totSet >= 2 : totSet >= 4
|
||||||
const soglia = isSetDecisivo ? 15 : 25
|
const soglia = isSetDecisivo ? 15 : 25
|
||||||
if (home >= soglia && home - guest >= 2) return 'home'
|
if (home >= soglia && home - guest >= 2) return 'home'
|
||||||
if (guest >= soglia && guest - home >= 2) return 'guest'
|
if (guest >= soglia && guest - home >= 2) return 'guest'
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
isPartitaFinita() {
|
||||||
|
if (!this.setVintoTeam || this.state.modalitaPartita === 'amichevole') return false
|
||||||
|
const setsToWin = this.state.modalitaPartita === '2/3' ? 2 : 3
|
||||||
|
return this.set[this.setVintoTeam] + 1 >= setsToWin
|
||||||
|
},
|
||||||
cambiValid() {
|
cambiValid() {
|
||||||
let hasComplete = false
|
let hasComplete = false
|
||||||
let allValid = true
|
for (const c of this.cambiData) {
|
||||||
this.cambiData.forEach((c) => {
|
const cin = (c.in || "").trim(), cout = (c.out || "").trim()
|
||||||
const cin = (c.in || "").trim()
|
if (!cin && !cout) continue
|
||||||
const cout = (c.out || "").trim()
|
if (!cin || !cout) return false
|
||||||
if (!cin && !cout) return
|
|
||||||
if (!cin || !cout) { allValid = false; return }
|
|
||||||
hasComplete = true
|
hasComplete = true
|
||||||
})
|
}
|
||||||
return allValid && hasComplete
|
return hasComplete
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
squadraVincente(val) {
|
squadraVincente(val) {
|
||||||
@@ -271,231 +253,55 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
|
||||||
this.connectWebSocket()
|
|
||||||
|
|
||||||
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
|
|
||||||
if (import.meta.hot) {
|
|
||||||
import.meta.hot.on('vite:beforeUpdate', () => {
|
|
||||||
// Annulla eventuali tentativi di riconnessione pianificati.
|
|
||||||
if (this.reconnectTimeout) {
|
|
||||||
clearTimeout(this.reconnectTimeout)
|
|
||||||
this.reconnectTimeout = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
// Pulisce il timeout di riconnessione.
|
|
||||||
if (this.reconnectTimeout) {
|
|
||||||
clearTimeout(this.reconnectTimeout)
|
|
||||||
this.reconnectTimeout = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chiude il WebSocket con il codice di chiusura appropriato.
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
|
|
||||||
this.ws.onerror = null
|
|
||||||
this.ws.onmessage = null
|
|
||||||
this.ws.onopen = null
|
|
||||||
|
|
||||||
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
|
|
||||||
try {
|
|
||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.close(1000, 'Component unmounting')
|
|
||||||
} else if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
||||||
// Se la connessione e ancora in fase di apertura, chiude direttamente.
|
|
||||||
this.ws.close()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Controller] Error closing WebSocket:', err)
|
|
||||||
}
|
|
||||||
this.ws = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
connectWebSocket() {
|
onWsMessage(msg) {
|
||||||
// Evita connessioni simultanee multiple.
|
if (msg.type === 'error') this.showErrorFeedback(msg.message)
|
||||||
if (this.isConnecting) {
|
|
||||||
console.log('[Controller] Already connecting, skipping...')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chiude la connessione precedente, se presente.
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.onclose = null
|
|
||||||
this.ws.onerror = null
|
|
||||||
this.ws.onmessage = null
|
|
||||||
this.ws.onopen = null
|
|
||||||
try {
|
|
||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.close(1000, 'Reconnecting')
|
|
||||||
} else if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
||||||
this.ws.close()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Controller] Error closing previous WebSocket:', err)
|
|
||||||
}
|
|
||||||
this.ws = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isConnecting = true
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
|
|
||||||
// Permette di specificare un host WebSocket alternativo via query parameter
|
|
||||||
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:3001
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
const defaultHost = (
|
|
||||||
location.hostname === 'localhost' || location.hostname === '::1'
|
|
||||||
)
|
|
||||||
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
|
|
||||||
: location.host
|
|
||||||
const wsHost = params.get('wsHost') || defaultHost
|
|
||||||
const wsUrl = `${protocol}//${wsHost}/ws`
|
|
||||||
|
|
||||||
console.log('[Controller] Connecting to WebSocket:', wsUrl)
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.ws = new WebSocket(wsUrl)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Controller] Failed to create WebSocket:', err)
|
|
||||||
this.isConnecting = false
|
|
||||||
this.scheduleReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this.isConnecting = false
|
|
||||||
this.wsConnected = true
|
|
||||||
this.reconnectAttempts = 0
|
|
||||||
console.log('[Controller] Connected to server')
|
|
||||||
|
|
||||||
// Invia la registrazione solo se la connessione e realmente aperta.
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
try {
|
|
||||||
this.ws.send(JSON.stringify({ type: 'register', role: 'controller' }))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Controller] Failed to register:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
|
|
||||||
if (msg.type === 'state') {
|
|
||||||
this.state = msg.state
|
|
||||||
} else if (msg.type === 'error') {
|
|
||||||
console.error('[Controller] Server error:', msg.message)
|
|
||||||
// Fornisce feedback di errore all'utente.
|
|
||||||
this.showErrorFeedback(msg.message)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Controller] Parse error:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
|
||||||
this.isConnecting = false
|
|
||||||
this.wsConnected = false
|
|
||||||
console.log('[Controller] Disconnected from server', event.code, event.reason)
|
|
||||||
|
|
||||||
// Non riconnette durante HMR (codice 1001, "going away")
|
|
||||||
// ne in caso di chiusura pulita (codice 1000).
|
|
||||||
if (event.code === 1000 || event.code === 1001) {
|
|
||||||
console.log('[Controller] Clean close, not reconnecting')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scheduleReconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onerror = (err) => {
|
|
||||||
console.error('[Controller] WebSocket error:', err)
|
|
||||||
this.isConnecting = false
|
|
||||||
this.wsConnected = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
scheduleReconnect() {
|
|
||||||
// Evita pianificazioni multiple della riconnessione.
|
|
||||||
if (this.reconnectTimeout) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
|
|
||||||
const delay = Math.min(
|
|
||||||
1000 * Math.pow(2, this.reconnectAttempts),
|
|
||||||
this.maxReconnectDelay
|
|
||||||
)
|
|
||||||
this.reconnectAttempts++
|
|
||||||
|
|
||||||
console.log(`[Controller] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
|
|
||||||
|
|
||||||
this.reconnectTimeout = setTimeout(() => {
|
|
||||||
this.reconnectTimeout = null
|
|
||||||
this.connectWebSocket()
|
|
||||||
}, delay)
|
|
||||||
},
|
|
||||||
|
|
||||||
sendAction(action) {
|
sendAction(action) {
|
||||||
if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
if (!action?.type) return
|
||||||
console.warn('[Controller] Cannot send action: not connected')
|
if (!this.sendWs({ type: 'action', action })) this.showErrorFeedback('Non connesso al server')
|
||||||
this.showErrorFeedback('Non connesso al server')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valida l'azione prima dell'invio.
|
|
||||||
if (!action || !action.type) {
|
|
||||||
console.error('[Controller] Invalid action format:', action)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.ws.send(JSON.stringify({ type: 'action', action }))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Controller] Failed to send action:', err)
|
|
||||||
this.showErrorFeedback('Errore invio comando')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
showErrorFeedback(message) {
|
showErrorFeedback(message) {
|
||||||
// Feedback visivo degli errori: attualmente solo log su console.
|
|
||||||
// In futuro puo essere esteso con notifiche a comparsa (toast).
|
|
||||||
console.error('[Controller] Error:', message)
|
console.error('[Controller] Error:', message)
|
||||||
},
|
},
|
||||||
|
|
||||||
doReset() {
|
doReset() {
|
||||||
this.sendAction({ type: 'resetta' })
|
this.sendAction({ type: 'resetta' })
|
||||||
this.confirmReset = false
|
this.confirmReset = false
|
||||||
|
this.configData = {
|
||||||
|
nomeHome: this.state.sp.nomi.home,
|
||||||
|
nomeGuest: this.state.sp.nomi.guest,
|
||||||
|
modalita: this.state.modalitaPartita,
|
||||||
|
formHome: ["1", "2", "3", "4", "5", "6"],
|
||||||
|
formGuest: ["1", "2", "3", "4", "5", "6"],
|
||||||
|
}
|
||||||
|
this.showConfig = true
|
||||||
},
|
},
|
||||||
|
|
||||||
undoUltimoPoint() {
|
undoUltimoPoint() {
|
||||||
this.sendAction({ type: 'decPunt' })
|
this.sendAction({ type: 'decPunt' })
|
||||||
this.showSetVinto = false
|
this.showSetVinto = false
|
||||||
},
|
},
|
||||||
|
|
||||||
doNuovoSet() {
|
doNuovoSet() {
|
||||||
this.sendAction({ type: 'nuovoSet', team: this.setVintoTeam })
|
this.sendAction({ type: 'nuovoSet', team: this.setVintoTeam })
|
||||||
this.showSetVinto = false
|
this.showSetVinto = false
|
||||||
this.configData.nomeHome = this.state.sp.nomi.home
|
this.configData = {
|
||||||
this.configData.nomeGuest = this.state.sp.nomi.guest
|
nomeHome: this.state.sp.nomi.home,
|
||||||
this.configData.modalita = this.state.modalitaPartita
|
nomeGuest: this.state.sp.nomi.guest,
|
||||||
this.configData.formHome = ["1", "2", "3", "4", "5", "6"]
|
modalita: this.state.modalitaPartita,
|
||||||
this.configData.formGuest = ["1", "2", "3", "4", "5", "6"]
|
formHome: ["1", "2", "3", "4", "5", "6"],
|
||||||
|
formGuest: ["1", "2", "3", "4", "5", "6"],
|
||||||
|
}
|
||||||
this.showConfig = true
|
this.showConfig = true
|
||||||
},
|
},
|
||||||
|
|
||||||
openConfig() {
|
openConfig() {
|
||||||
this.configData.nomeHome = this.state.sp.nomi.home
|
this.configData = {
|
||||||
this.configData.nomeGuest = this.state.sp.nomi.guest
|
nomeHome: this.state.sp.nomi.home,
|
||||||
this.configData.modalita = this.state.modalitaPartita
|
nomeGuest: this.state.sp.nomi.guest,
|
||||||
this.configData.formHome = [...this.state.sp.form.home]
|
modalita: this.state.modalitaPartita,
|
||||||
this.configData.formGuest = [...this.state.sp.form.guest]
|
formHome: [...this.state.sp.form.home],
|
||||||
|
formGuest: [...this.state.sp.form.guest],
|
||||||
|
}
|
||||||
this.showConfig = true
|
this.showConfig = true
|
||||||
},
|
},
|
||||||
|
|
||||||
saveConfig() {
|
saveConfig() {
|
||||||
this.sendAction({ type: 'setNomi', home: this.configData.nomeHome, guest: this.configData.nomeGuest })
|
this.sendAction({ type: 'setNomi', home: this.configData.nomeHome, guest: this.configData.nomeGuest })
|
||||||
this.sendAction({ type: 'setModalita', modalita: this.configData.modalita })
|
this.sendAction({ type: 'setModalita', modalita: this.configData.modalita })
|
||||||
@@ -503,11 +309,7 @@ export default {
|
|||||||
this.sendAction({ type: 'setFormazione', team: 'guest', form: this.configData.formGuest })
|
this.sendAction({ type: 'setFormazione', team: 'guest', form: this.configData.formGuest })
|
||||||
this.showConfig = false
|
this.showConfig = false
|
||||||
},
|
},
|
||||||
|
openCambiTeam() { this.showCambiTeam = true },
|
||||||
openCambiTeam() {
|
|
||||||
this.showCambiTeam = true
|
|
||||||
},
|
|
||||||
|
|
||||||
openCambi(team) {
|
openCambi(team) {
|
||||||
this.showCambiTeam = false
|
this.showCambiTeam = false
|
||||||
this.cambiTeam = team
|
this.cambiTeam = team
|
||||||
@@ -515,76 +317,44 @@ export default {
|
|||||||
this.cambiError = ""
|
this.cambiError = ""
|
||||||
this.showCambi = true
|
this.showCambi = true
|
||||||
},
|
},
|
||||||
|
|
||||||
closeCambi() {
|
closeCambi() {
|
||||||
this.showCambi = false
|
this.showCambi = false
|
||||||
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
|
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
|
||||||
this.cambiError = ""
|
this.cambiError = ""
|
||||||
},
|
},
|
||||||
|
|
||||||
confermaCambi() {
|
confermaCambi() {
|
||||||
if (!this.cambiValid) return
|
if (!this.cambiValid) return
|
||||||
this.cambiError = ""
|
this.cambiError = ""
|
||||||
|
|
||||||
const cambi = this.cambiData
|
const cambi = this.cambiData
|
||||||
.filter(c => (c.in || "").trim() && (c.out || "").trim())
|
.filter(c => (c.in || "").trim() && (c.out || "").trim())
|
||||||
.map(c => ({ in: c.in.trim(), out: c.out.trim() }))
|
.map(c => ({ in: c.in.trim(), out: c.out.trim() }))
|
||||||
|
const formSimulata = this.state.sp.form[this.cambiTeam].map(v => String(v).trim())
|
||||||
// Simula i cambi in sequenza per validare
|
|
||||||
const formCorrente = this.state.sp.form[this.cambiTeam].map(v => String(v).trim())
|
|
||||||
const formSimulata = [...formCorrente]
|
|
||||||
|
|
||||||
for (const cambio of cambi) {
|
for (const cambio of cambi) {
|
||||||
if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) {
|
if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) {
|
||||||
this.cambiError = "I numeri dei giocatori devono essere cifre"
|
this.cambiError = "I numeri dei giocatori devono essere cifre"; return
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (cambio.in === cambio.out) {
|
if (cambio.in === cambio.out) {
|
||||||
this.cambiError = `Il giocatore ${cambio.in} non può sostituire sé stesso`
|
this.cambiError = `Il giocatore ${cambio.in} non può sostituire sé stesso`; return
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (formSimulata.includes(cambio.in)) {
|
if (formSimulata.includes(cambio.in)) {
|
||||||
this.cambiError = `Il giocatore ${cambio.in} è già in formazione`
|
this.cambiError = `Il giocatore ${cambio.in} è già in formazione`; return
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (!formSimulata.includes(cambio.out)) {
|
if (!formSimulata.includes(cambio.out)) {
|
||||||
this.cambiError = `Il giocatore ${cambio.out} non è in formazione`
|
this.cambiError = `Il giocatore ${cambio.out} non è in formazione`; return
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const idx = formSimulata.indexOf(cambio.out)
|
formSimulata.splice(formSimulata.indexOf(cambio.out), 1, cambio.in)
|
||||||
formSimulata.splice(idx, 1, cambio.in)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendAction({ type: 'confermaCambi', team: this.cambiTeam, cambi })
|
this.sendAction({ type: 'confermaCambi', team: this.cambiTeam, cambi })
|
||||||
this.closeCambi()
|
this.closeCambi()
|
||||||
},
|
},
|
||||||
|
|
||||||
speak() {
|
speak() {
|
||||||
let text = ''
|
const { home, guest } = this.punt
|
||||||
if (this.state.sp.punt.home + this.state.sp.punt.guest === 0) {
|
const text = home + guest === 0 ? "zero a zero"
|
||||||
text = "zero a zero"
|
: home === guest ? `${home} pari`
|
||||||
} else if (this.state.sp.punt.home === this.state.sp.punt.guest) {
|
: this.servHome ? `${home} a ${guest}` : `${guest} a ${home}`
|
||||||
text = this.state.sp.punt.home + " pari"
|
this.sendWs({ type: 'speak', text })
|
||||||
} else {
|
},
|
||||||
if (this.state.sp.servHome) {
|
},
|
||||||
text = this.state.sp.punt.home + " a " + this.state.sp.punt.guest
|
|
||||||
} else {
|
|
||||||
text = this.state.sp.punt.guest + " a " + this.state.sp.punt.home
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
||||||
this.showErrorFeedback('Non connesso al server')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.ws.send(JSON.stringify({ type: 'speak', text }))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Controller] Failed to send speak command:', err)
|
|
||||||
this.showErrorFeedback('Errore invio comando voce')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+31
-225
@@ -7,22 +7,22 @@
|
|||||||
<span :style="{ 'float': 'left' }">
|
<span :style="{ 'float': 'left' }">
|
||||||
{{ state.sp.nomi.home }}
|
{{ state.sp.nomi.home }}
|
||||||
<span class="serv-slot">
|
<span class="serv-slot">
|
||||||
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
|
<img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
|
||||||
</span>
|
</span>
|
||||||
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
|
<span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.home }}</span>
|
<span class="mr3" :style="{ 'float': 'right' }">set {{ set.home }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hea guest">
|
<div class="hea guest">
|
||||||
<span :style="{ 'float': 'right' }">
|
<span :style="{ 'float': 'right' }">
|
||||||
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
|
<span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
|
||||||
<span class="serv-slot">
|
<span class="serv-slot">
|
||||||
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
|
<img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
|
||||||
</span>
|
</span>
|
||||||
{{ state.sp.nomi.guest }}
|
{{ state.sp.nomi.guest }}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.guest }}</span>
|
<span class="ml3" :style="{ 'float': 'left' }">set {{ set.guest }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span v-if="state.visuForm">
|
<span v-if="state.visuForm">
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<div class="punteggio-container">
|
<div class="punteggio-container">
|
||||||
<div class="col punt home">{{ state.sp.punt.home }}</div>
|
<div class="col punt home">{{ punt.home }}</div>
|
||||||
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
|
<div class="col punt guest">{{ punt.guest }}</div>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -51,22 +51,22 @@
|
|||||||
<span :style="{ 'float': 'left' }">
|
<span :style="{ 'float': 'left' }">
|
||||||
{{ state.sp.nomi.guest }}
|
{{ state.sp.nomi.guest }}
|
||||||
<span class="serv-slot">
|
<span class="serv-slot">
|
||||||
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
|
<img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
|
||||||
</span>
|
</span>
|
||||||
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
|
<span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.guest }}</span>
|
<span class="mr3" :style="{ 'float': 'right' }">set {{ set.guest }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hea home">
|
<div class="hea home">
|
||||||
<span :style="{ 'float': 'right' }">
|
<span :style="{ 'float': 'right' }">
|
||||||
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
|
<span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
|
||||||
<span class="serv-slot">
|
<span class="serv-slot">
|
||||||
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
|
<img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
|
||||||
</span>
|
</span>
|
||||||
{{ state.sp.nomi.home }}
|
{{ state.sp.nomi.home }}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.home }}</span>
|
<span class="ml3" :style="{ 'float': 'left' }">set {{ set.home }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span v-if="state.visuForm">
|
<span v-if="state.visuForm">
|
||||||
@@ -83,8 +83,8 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<div class="punteggio-container">
|
<div class="punteggio-container">
|
||||||
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
|
<div class="col punt guest">{{ punt.guest }}</div>
|
||||||
<div class="col punt home">{{ state.sp.punt.home }}</div>
|
<div class="col punt home">{{ punt.home }}</div>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -116,80 +116,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { createWsMixin } from '../wsMixin.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "DisplayPage",
|
name: "DisplayPage",
|
||||||
data() {
|
mixins: [createWsMixin('display')],
|
||||||
return {
|
|
||||||
ws: null,
|
|
||||||
wsConnected: false,
|
|
||||||
isConnecting: false,
|
|
||||||
reconnectTimeout: null,
|
|
||||||
reconnectAttempts: 0,
|
|
||||||
maxReconnectDelay: 30000, // Ritardo massimo di riconnessione: 30 secondi
|
|
||||||
state: {
|
|
||||||
order: true,
|
|
||||||
visuForm: false,
|
|
||||||
visuStriscia: true,
|
|
||||||
modalitaPartita: "3/5",
|
|
||||||
sp: {
|
|
||||||
striscia: [{ serv: 'home', r: [] }],
|
|
||||||
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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.connectWebSocket()
|
|
||||||
// Attiva la modalita fullscreen su dispositivi mobili.
|
|
||||||
if (this.isMobile()) {
|
if (this.isMobile()) {
|
||||||
try { document.documentElement.requestFullscreen() } catch (e) {}
|
try { document.documentElement.requestFullscreen() } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
|
|
||||||
if (import.meta.hot) {
|
|
||||||
import.meta.hot.on('vite:beforeUpdate', () => {
|
|
||||||
// Annulla eventuali tentativi di riconnessione pianificati.
|
|
||||||
if (this.reconnectTimeout) {
|
|
||||||
clearTimeout(this.reconnectTimeout)
|
|
||||||
this.reconnectTimeout = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
// Pulisce il timeout di riconnessione.
|
|
||||||
if (this.reconnectTimeout) {
|
|
||||||
clearTimeout(this.reconnectTimeout)
|
|
||||||
this.reconnectTimeout = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chiude il WebSocket con il codice di chiusura appropriato.
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
|
|
||||||
this.ws.onerror = null
|
|
||||||
this.ws.onmessage = null
|
|
||||||
this.ws.onopen = null
|
|
||||||
|
|
||||||
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
|
|
||||||
try {
|
|
||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.close(1000, 'Component unmounting')
|
|
||||||
} else if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
||||||
// Se la connessione e ancora in fase di apertura, chiude direttamente.
|
|
||||||
this.ws.close()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Display] Error closing WebSocket:', err)
|
|
||||||
}
|
|
||||||
this.ws = null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
stricciaStrip() {
|
stricciaStrip() {
|
||||||
@@ -197,8 +132,8 @@ export default {
|
|||||||
if (!currentSet) return { home: [], guest: [] }
|
if (!currentSet) return { home: [], guest: [] }
|
||||||
let h = 0, g = 0
|
let h = 0, g = 0
|
||||||
const home = [], guest = []
|
const home = [], guest = []
|
||||||
for (const scorer of currentSet.r) {
|
for (const scorer of currentSet.ris) {
|
||||||
if (scorer === 'home') { h++; home.push(h); guest.push(' ') }
|
if (scorer === 'h') { h++; home.push(h); guest.push(' ') }
|
||||||
else { g++; guest.push(g); home.push(' ') }
|
else { g++; guest.push(g); home.push(' ') }
|
||||||
}
|
}
|
||||||
return { home, guest }
|
return { home, guest }
|
||||||
@@ -216,154 +151,25 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
onWsMessage(msg) {
|
||||||
|
if (msg.type === 'speak') this.speakOnDisplay(msg.text)
|
||||||
|
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
|
||||||
|
},
|
||||||
isMobile() {
|
isMobile() {
|
||||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||||
},
|
},
|
||||||
connectWebSocket() {
|
|
||||||
// Evita connessioni simultanee multiple.
|
|
||||||
if (this.isConnecting) {
|
|
||||||
console.log('[Display] Already connecting, skipping...')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chiude la connessione precedente, se presente.
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.onclose = null
|
|
||||||
this.ws.onerror = null
|
|
||||||
this.ws.onmessage = null
|
|
||||||
this.ws.onopen = null
|
|
||||||
try {
|
|
||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.close(1000, 'Reconnecting')
|
|
||||||
} else if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
||||||
this.ws.close()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Display] Error closing previous WebSocket:', err)
|
|
||||||
}
|
|
||||||
this.ws = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isConnecting = true
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
|
|
||||||
// Permette di specificare un host WebSocket alternativo via query parameter
|
|
||||||
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:5173
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
const defaultHost = (
|
|
||||||
location.hostname === 'localhost' || location.hostname === '::1'
|
|
||||||
)
|
|
||||||
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
|
|
||||||
: location.host
|
|
||||||
const wsHost = params.get('wsHost') || defaultHost
|
|
||||||
const wsUrl = `${protocol}//${wsHost}/ws`
|
|
||||||
|
|
||||||
console.log('[Display] Connecting to WebSocket:', wsUrl)
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.ws = new WebSocket(wsUrl)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Display] Failed to create WebSocket:', err)
|
|
||||||
this.isConnecting = false
|
|
||||||
this.scheduleReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this.isConnecting = false
|
|
||||||
this.wsConnected = true
|
|
||||||
this.reconnectAttempts = 0
|
|
||||||
console.log('[Display] Connected to server')
|
|
||||||
|
|
||||||
// Registra il client come display solo con connessione effettivamente aperta.
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
try {
|
|
||||||
this.ws.send(JSON.stringify({ type: 'register', role: 'display' }))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Display] Failed to register:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
|
|
||||||
if (msg.type === 'state') {
|
|
||||||
this.state = msg.state
|
|
||||||
} else if (msg.type === 'speak') {
|
|
||||||
this.speakOnDisplay(msg.text)
|
|
||||||
} else if (msg.type === 'error') {
|
|
||||||
console.error('[Display] Server error:', msg.message)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Display] Error parsing message:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
|
||||||
this.isConnecting = false
|
|
||||||
this.wsConnected = false
|
|
||||||
console.log('[Display] Disconnected from server', event.code, event.reason)
|
|
||||||
|
|
||||||
// Non riconnette durante HMR (codice 1001, "going away")
|
|
||||||
// ne in caso di chiusura pulita (codice 1000).
|
|
||||||
if (event.code === 1000 || event.code === 1001) {
|
|
||||||
console.log('[Display] Clean close, not reconnecting')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scheduleReconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws.onerror = (err) => {
|
|
||||||
console.error('[Display] WebSocket error:', err)
|
|
||||||
this.isConnecting = false
|
|
||||||
this.wsConnected = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scheduleReconnect() {
|
|
||||||
// Evita pianificazioni multiple della riconnessione.
|
|
||||||
if (this.reconnectTimeout) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
|
|
||||||
const delay = Math.min(
|
|
||||||
1000 * Math.pow(2, this.reconnectAttempts),
|
|
||||||
this.maxReconnectDelay
|
|
||||||
)
|
|
||||||
this.reconnectAttempts++
|
|
||||||
|
|
||||||
console.log(`[Display] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
|
|
||||||
|
|
||||||
this.reconnectTimeout = setTimeout(() => {
|
|
||||||
this.reconnectTimeout = null
|
|
||||||
this.connectWebSocket()
|
|
||||||
}, delay)
|
|
||||||
},
|
|
||||||
speakOnDisplay(text) {
|
speakOnDisplay(text) {
|
||||||
if (typeof text !== 'string' || !text.trim()) {
|
if (typeof text !== 'string' || !text.trim() || !('speechSynthesis' in window)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!('speechSynthesis' in window)) {
|
|
||||||
console.warn('[Display] speechSynthesis not supported')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(text.trim())
|
const utterance = new SpeechSynthesisUtterance(text.trim())
|
||||||
const voices = window.speechSynthesis.getVoices()
|
const voices = window.speechSynthesis.getVoices()
|
||||||
const preferredVoice = voices.find((v) => v.name === 'Google italiano')
|
utterance.voice = voices.find(v => v.name === 'Google italiano')
|
||||||
|| voices.find((v) => v.lang && v.lang.toLowerCase().startsWith('it'))
|
|| voices.find(v => v.lang?.toLowerCase().startsWith('it'))
|
||||||
if (preferredVoice) {
|
|| null
|
||||||
utterance.voice = preferredVoice
|
|
||||||
}
|
|
||||||
utterance.lang = 'it-IT'
|
utterance.lang = 'it-IT'
|
||||||
|
|
||||||
window.speechSynthesis.cancel()
|
window.speechSynthesis.cancel()
|
||||||
window.speechSynthesis.speak(utterance)
|
window.speechSynthesis.speak(utterance)
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+59
-39
@@ -1,3 +1,21 @@
|
|||||||
|
export function punteggio(striscia) {
|
||||||
|
let home = 0, guest = 0
|
||||||
|
for (const c of striscia.at(-1).ris) c === 'h' ? home++ : guest++
|
||||||
|
return { home, guest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function servizio(striscia) {
|
||||||
|
const set = striscia.at(-1)
|
||||||
|
return set.ris.length === 0 ? set.serv === 'h' : set.ris.at(-1) === 'h'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVinti(striscia) {
|
||||||
|
return {
|
||||||
|
home: striscia.filter(s => s.vinc === 'h').length,
|
||||||
|
guest: striscia.filter(s => s.vinc === 'g').length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
order: true,
|
order: true,
|
||||||
@@ -5,10 +23,7 @@ export function createInitialState() {
|
|||||||
visuStriscia: true,
|
visuStriscia: true,
|
||||||
modalitaPartita: "3/5",
|
modalitaPartita: "3/5",
|
||||||
sp: {
|
sp: {
|
||||||
striscia: [{ serv: 'home', r: [] }],
|
striscia: [{ serv: 'h', ris: '', vinc: null }],
|
||||||
servHome: true,
|
|
||||||
punt: { home: 0, guest: 0 },
|
|
||||||
set: { home: 0, guest: 0 },
|
|
||||||
nomi: { home: "Antoniana", guest: "Guest" },
|
nomi: { home: "Antoniana", guest: "Guest" },
|
||||||
form: {
|
form: {
|
||||||
home: ["1", "2", "3", "4", "5", "6"],
|
home: ["1", "2", "3", "4", "5", "6"],
|
||||||
@@ -19,12 +34,9 @@ export function createInitialState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function checkVittoria(state) {
|
export function checkVittoria(state) {
|
||||||
const puntHome = state.sp.punt.home
|
const { home: puntHome, guest: puntGuest } = punteggio(state.sp.striscia)
|
||||||
const puntGuest = state.sp.punt.guest
|
const sv = setVinti(state.sp.striscia)
|
||||||
const setHome = state.sp.set.home
|
const totSet = sv.home + sv.guest
|
||||||
const setGuest = state.sp.set.guest
|
|
||||||
const totSet = setHome + setGuest
|
|
||||||
|
|
||||||
const isSetDecisivo = state.modalitaPartita === "2/3" ? totSet >= 2 : totSet >= 4
|
const isSetDecisivo = state.modalitaPartita === "2/3" ? totSet >= 2 : totSet >= 4
|
||||||
const punteggioVittoria = isSetDecisivo ? 15 : 25
|
const punteggioVittoria = isSetDecisivo ? 15 : 25
|
||||||
|
|
||||||
@@ -33,6 +45,13 @@ export function checkVittoria(state) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkVittoriaPartita(state) {
|
||||||
|
if (state.modalitaPartita === 'amichevole') return false
|
||||||
|
const setsToWin = state.modalitaPartita === "2/3" ? 2 : 3
|
||||||
|
const sv = setVinti(state.sp.striscia)
|
||||||
|
return sv.home >= setsToWin || sv.guest >= setsToWin
|
||||||
|
}
|
||||||
|
|
||||||
export function applyAction(state, action) {
|
export function applyAction(state, action) {
|
||||||
const s = structuredClone(state)
|
const s = structuredClone(state)
|
||||||
|
|
||||||
@@ -41,32 +60,30 @@ export function applyAction(state, action) {
|
|||||||
const team = action.team
|
const team = action.team
|
||||||
if (checkVittoria(s)) break
|
if (checkVittoria(s)) break
|
||||||
|
|
||||||
const cambioPalla = (team === "home") !== s.sp.servHome
|
const servHome = servizio(s.sp.striscia)
|
||||||
s.sp.punt[team]++
|
const cambioPalla = (team === "home") !== servHome
|
||||||
s.sp.striscia.at(-1).r.push(team)
|
s.sp.striscia.at(-1).ris += team === 'home' ? 'h' : 'g'
|
||||||
|
|
||||||
if (cambioPalla) {
|
if (cambioPalla) {
|
||||||
s.sp.form[team].push(s.sp.form[team].shift())
|
s.sp.form[team].push(s.sp.form[team].shift())
|
||||||
}
|
}
|
||||||
|
|
||||||
s.sp.servHome = team === "home"
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "decPunt": {
|
case "decPunt": {
|
||||||
const currentSet = s.sp.striscia.at(-1)
|
const currentSet = s.sp.striscia.at(-1)
|
||||||
if (currentSet.r.length === 0) break
|
if (currentSet.ris.length === 0) break
|
||||||
|
|
||||||
const lastScorer = currentSet.r[currentSet.r.length - 1]
|
const lastScorerShort = currentSet.ris.at(-1)
|
||||||
const prevServer = currentSet.r.length >= 2
|
const prevServerShort = currentSet.ris.length >= 2
|
||||||
? currentSet.r[currentSet.r.length - 2]
|
? currentSet.ris.at(-2)
|
||||||
: currentSet.serv
|
: currentSet.serv
|
||||||
|
|
||||||
const wasCambioPalla = lastScorer !== prevServer
|
const wasCambioPalla = lastScorerShort !== prevServerShort
|
||||||
|
|
||||||
currentSet.r.pop()
|
currentSet.ris = currentSet.ris.slice(0, -1)
|
||||||
s.sp.punt[lastScorer]--
|
|
||||||
s.sp.servHome = prevServer === 'home'
|
const lastScorer = lastScorerShort === 'h' ? 'home' : 'guest'
|
||||||
|
|
||||||
if (wasCambioPalla) {
|
if (wasCambioPalla) {
|
||||||
s.sp.form[lastScorer].unshift(s.sp.form[lastScorer].pop())
|
s.sp.form[lastScorer].unshift(s.sp.form[lastScorer].pop())
|
||||||
@@ -76,10 +93,17 @@ export function applyAction(state, action) {
|
|||||||
|
|
||||||
case "incSet": {
|
case "incSet": {
|
||||||
const team = action.team
|
const team = action.team
|
||||||
if (s.sp.set[team] === 2) {
|
const sv = setVinti(s.sp.striscia)
|
||||||
s.sp.set[team] = 0
|
const count = sv[team]
|
||||||
|
const teamShort = team === 'home' ? 'h' : 'g'
|
||||||
|
if (count >= 2) {
|
||||||
|
// cicla a 0: rimuove le voci fantasma per questo team
|
||||||
|
s.sp.striscia = s.sp.striscia.filter(
|
||||||
|
entry => !(entry._phantom && entry.vinc === teamShort)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
s.sp.set[team]++
|
// inserisce una voce fantasma prima dell'ultimo set (quello in corso)
|
||||||
|
s.sp.striscia.splice(-1, 0, { serv: teamShort, ris: '', vinc: teamShort, _phantom: true })
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -87,11 +111,10 @@ export function applyAction(state, action) {
|
|||||||
case "nuovoSet": {
|
case "nuovoSet": {
|
||||||
const team = action.team
|
const team = action.team
|
||||||
if (team !== 'home' && team !== 'guest') break
|
if (team !== 'home' && team !== 'guest') break
|
||||||
s.sp.set[team]++
|
if (checkVittoriaPartita(s)) break
|
||||||
s.sp.punt.home = 0
|
s.sp.striscia.at(-1).vinc = team === 'home' ? 'h' : 'g'
|
||||||
s.sp.punt.guest = 0
|
if (checkVittoriaPartita(s)) break
|
||||||
s.sp.servHome = team === 'home'
|
s.sp.striscia.push({ serv: team === 'home' ? 'h' : 'g', ris: '', vinc: null })
|
||||||
s.sp.striscia.push({ serv: team, r: [] })
|
|
||||||
s.sp.form = {
|
s.sp.form = {
|
||||||
home: ["1", "2", "3", "4", "5", "6"],
|
home: ["1", "2", "3", "4", "5", "6"],
|
||||||
guest: ["1", "2", "3", "4", "5", "6"],
|
guest: ["1", "2", "3", "4", "5", "6"],
|
||||||
@@ -100,24 +123,21 @@ export function applyAction(state, action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "cambiaPalla": {
|
case "cambiaPalla": {
|
||||||
if (s.sp.punt.home === 0 && s.sp.punt.guest === 0) {
|
const currentSet = s.sp.striscia.at(-1)
|
||||||
s.sp.servHome = !s.sp.servHome
|
if (currentSet.ris.length === 0) {
|
||||||
s.sp.striscia.at(-1).serv = s.sp.servHome ? 'home' : 'guest'
|
currentSet.serv = currentSet.serv === 'h' ? 'g' : 'h'
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "resetta": {
|
case "resetta": {
|
||||||
s.visuForm = false
|
s.visuForm = false
|
||||||
s.sp.punt.home = 0
|
const servIniziale = s.sp.striscia[0]?.serv ?? 'h'
|
||||||
s.sp.punt.guest = 0
|
s.sp.striscia = [{ serv: servIniziale, ris: '', vinc: null }]
|
||||||
s.sp.set.home = 0
|
|
||||||
s.sp.set.guest = 0
|
|
||||||
s.sp.form = {
|
s.sp.form = {
|
||||||
home: ["1", "2", "3", "4", "5", "6"],
|
home: ["1", "2", "3", "4", "5", "6"],
|
||||||
guest: ["1", "2", "3", "4", "5", "6"],
|
guest: ["1", "2", "3", "4", "5", "6"],
|
||||||
}
|
}
|
||||||
s.sp.striscia = [{ serv: s.sp.servHome ? 'home' : 'guest', r: [] }]
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-14
@@ -1,20 +1,34 @@
|
|||||||
import { networkInterfaces } from 'os'
|
import { networkInterfaces } from 'os'
|
||||||
|
import { readFileSync, existsSync } from 'fs'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
|
||||||
|
function isWSL() {
|
||||||
|
try {
|
||||||
|
return existsSync('/proc/sys/kernel/osrelease') &&
|
||||||
|
readFileSync('/proc/sys/kernel/osrelease', 'utf8').toLowerCase().includes('microsoft')
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
export function getNetworkIPs() {
|
export function getNetworkIPs() {
|
||||||
const nets = networkInterfaces()
|
if (isWSL()) {
|
||||||
const networkIPs = []
|
try {
|
||||||
|
const out = execSync(
|
||||||
for (const name of Object.keys(nets)) {
|
'powershell.exe -NoProfile -Command "Get-NetIPAddress -AddressFamily IPv4 | Select-Object -ExpandProperty IPAddress"',
|
||||||
for (const net of nets[name]) {
|
{ timeout: 3000 }
|
||||||
if (net.family === 'IPv4' &&
|
)
|
||||||
!net.internal &&
|
return out.toString().trim().split('\n')
|
||||||
!net.address.startsWith('172.17.') &&
|
.map(s => s.trim())
|
||||||
!net.address.startsWith('172.18.')) {
|
.filter(ip => ip && !ip.startsWith('127.') && !ip.startsWith('169.254.') && !ip.startsWith('172.'))
|
||||||
networkIPs.push(net.address)
|
} catch { return [] }
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nets = networkInterfaces()
|
||||||
|
const networkIPs = []
|
||||||
|
for (const name of Object.keys(nets)) {
|
||||||
|
for (const net of nets[name]) {
|
||||||
|
if (net.family === 'IPv4' && !net.internal) networkIPs.push(net.address)
|
||||||
|
}
|
||||||
|
}
|
||||||
return networkIPs
|
return networkIPs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,9 +40,10 @@ export function printServerInfo(port = 3000) {
|
|||||||
console.log(` Controller: http://127.0.0.1:${port}/controller`)
|
console.log(` Controller: http://127.0.0.1:${port}/controller`)
|
||||||
|
|
||||||
if (networkIPs.length > 0) {
|
if (networkIPs.length > 0) {
|
||||||
console.log(`\n Controller da dispositivi remoti:`)
|
console.log(`\n Da dispositivi remoti:`)
|
||||||
networkIPs.forEach(ip => {
|
networkIPs.forEach(ip => {
|
||||||
console.log(` http://${ip}:${port}/controller`)
|
console.log(` Display: http://${ip}:${port}/display`)
|
||||||
|
console.log(` Controller: http://${ip}:${port}/controller`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
import { createInitialState, punteggio, servizio, setVinti } from './gameState.js'
|
||||||
|
|
||||||
|
export function createWsMixin(role) {
|
||||||
|
return {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ws: null,
|
||||||
|
wsConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
reconnectTimeout: null,
|
||||||
|
reconnectAttempts: 0,
|
||||||
|
maxReconnectDelay: 30000,
|
||||||
|
state: createInitialState(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
punt() { return punteggio(this.state.sp.striscia) },
|
||||||
|
servHome() { return servizio(this.state.sp.striscia) },
|
||||||
|
set() { return setVinti(this.state.sp.striscia) },
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.connectWebSocket()
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.on('vite:beforeUpdate', () => {
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout)
|
||||||
|
this.reconnectTimeout = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout)
|
||||||
|
this.reconnectTimeout = null
|
||||||
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.onclose = null
|
||||||
|
this.ws.onerror = null
|
||||||
|
this.ws.onmessage = null
|
||||||
|
this.ws.onopen = null
|
||||||
|
try {
|
||||||
|
if (this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.close(1000, 'Component unmounting')
|
||||||
|
} else if (this.ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
this.ws.close()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${role}] Error closing WebSocket:`, err)
|
||||||
|
}
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
connectWebSocket() {
|
||||||
|
if (this.isConnecting) return
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.onclose = null
|
||||||
|
this.ws.onerror = null
|
||||||
|
this.ws.onmessage = null
|
||||||
|
this.ws.onopen = null
|
||||||
|
try {
|
||||||
|
if (this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.close(1000, 'Reconnecting')
|
||||||
|
} else if (this.ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
this.ws.close()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${role}] Error closing previous WebSocket:`, err)
|
||||||
|
}
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnecting = true
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const defaultHost = (location.hostname === 'localhost' || location.hostname === '::1')
|
||||||
|
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
|
||||||
|
: location.host
|
||||||
|
const wsUrl = `${protocol}//${params.get('wsHost') || defaultHost}/ws`
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(wsUrl)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${role}] Failed to create WebSocket:`, err)
|
||||||
|
this.isConnecting = false
|
||||||
|
this.scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.isConnecting = false
|
||||||
|
this.wsConnected = true
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
try {
|
||||||
|
this.ws?.send(JSON.stringify({ type: 'register', role }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${role}] Failed to register:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
if (msg.type === 'state') this.state = msg.state
|
||||||
|
else this.onWsMessage?.(msg)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${role}] Error parsing message:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
this.isConnecting = false
|
||||||
|
this.wsConnected = false
|
||||||
|
if (event.code !== 1000 && event.code !== 1001) this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.isConnecting = false
|
||||||
|
this.wsConnected = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (this.reconnectTimeout) return
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay)
|
||||||
|
this.reconnectAttempts++
|
||||||
|
this.reconnectTimeout = setTimeout(() => {
|
||||||
|
this.reconnectTimeout = null
|
||||||
|
this.connectWebSocket()
|
||||||
|
}, delay)
|
||||||
|
},
|
||||||
|
|
||||||
|
sendWs(msg) {
|
||||||
|
if (!this.wsConnected || this.ws?.readyState !== WebSocket.OPEN) return false
|
||||||
|
try {
|
||||||
|
this.ws.send(JSON.stringify(msg))
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${role}] Failed to send:`, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { createInitialState, applyAction, checkVittoria } from '../../src/gameState.js'
|
import { createInitialState, applyAction, checkVittoria, checkVittoriaPartita } from '../../src/gameState.js'
|
||||||
|
|
||||||
describe('Game Logic (gameState.js)', () => {
|
describe('Game Logic (gameState.js)', () => {
|
||||||
let state
|
let state
|
||||||
@@ -33,8 +33,8 @@ describe('Game Logic (gameState.js)', () => {
|
|||||||
|
|
||||||
it('dovrebbe avere la striscia iniziale con un set vuoto', () => {
|
it('dovrebbe avere la striscia iniziale con un set vuoto', () => {
|
||||||
expect(state.sp.striscia).toHaveLength(1)
|
expect(state.sp.striscia).toHaveLength(1)
|
||||||
expect(state.sp.striscia[0].serv).toBe('home')
|
expect(state.sp.striscia[0].serv).toBe('h')
|
||||||
expect(state.sp.striscia[0].r).toEqual([])
|
expect(state.sp.striscia[0].ris).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dovrebbe avere modalità 3/5 di default', () => {
|
it('dovrebbe avere modalità 3/5 di default', () => {
|
||||||
@@ -113,19 +113,19 @@ describe('Game Logic (gameState.js)', () => {
|
|||||||
|
|
||||||
it('dovrebbe aggiornare la striscia per punto Home', () => {
|
it('dovrebbe aggiornare la striscia per punto Home', () => {
|
||||||
const s = applyAction(state, { type: 'incPunt', team: 'home' })
|
const s = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||||
expect(s.sp.striscia.at(-1).r).toEqual(['home'])
|
expect(s.sp.striscia.at(-1).ris).toBe('h')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dovrebbe aggiornare la striscia per punto Guest', () => {
|
it('dovrebbe aggiornare la striscia per punto Guest', () => {
|
||||||
const s = applyAction(state, { type: 'incPunt', team: 'guest' })
|
const s = applyAction(state, { type: 'incPunt', team: 'guest' })
|
||||||
expect(s.sp.striscia.at(-1).r).toEqual(['guest'])
|
expect(s.sp.striscia.at(-1).ris).toBe('g')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dovrebbe registrare scorer nella striscia', () => {
|
it('dovrebbe registrare scorer nella striscia', () => {
|
||||||
let s = applyAction(state, { type: 'incPunt', team: 'home' })
|
let s = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||||
s = applyAction(s, { type: 'incPunt', team: 'guest' })
|
s = applyAction(s, { type: 'incPunt', team: 'guest' })
|
||||||
s = applyAction(s, { type: 'incPunt', team: 'home' })
|
s = applyAction(s, { type: 'incPunt', team: 'home' })
|
||||||
expect(s.sp.striscia.at(-1).r).toEqual(['home', 'guest', 'home'])
|
expect(s.sp.striscia.at(-1).ris).toBe('hgh')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('non dovrebbe incrementare i punti dopo vittoria', () => {
|
it('non dovrebbe incrementare i punti dopo vittoria', () => {
|
||||||
@@ -180,7 +180,7 @@ describe('Game Logic (gameState.js)', () => {
|
|||||||
it('dovrebbe ripristinare la striscia', () => {
|
it('dovrebbe ripristinare la striscia', () => {
|
||||||
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
|
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||||
const s2 = applyAction(s1, { type: 'decPunt' })
|
const s2 = applyAction(s1, { type: 'decPunt' })
|
||||||
expect(s2.sp.striscia.at(-1).r).toEqual([])
|
expect(s2.sp.striscia.at(-1).ris).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dovrebbe gestire undo multipli in sequenza', () => {
|
it('dovrebbe gestire undo multipli in sequenza', () => {
|
||||||
@@ -248,14 +248,14 @@ describe('Game Logic (gameState.js)', () => {
|
|||||||
it('dovrebbe aggiungere un nuovo set vuoto alla striscia', () => {
|
it('dovrebbe aggiungere un nuovo set vuoto alla striscia', () => {
|
||||||
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
|
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
|
||||||
expect(s.sp.striscia).toHaveLength(2)
|
expect(s.sp.striscia).toHaveLength(2)
|
||||||
expect(s.sp.striscia.at(-1).r).toEqual([])
|
expect(s.sp.striscia.at(-1).ris).toBe('')
|
||||||
expect(s.sp.striscia.at(-1).serv).toBe('home')
|
expect(s.sp.striscia.at(-1).serv).toBe('h')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dovrebbe conservare il set precedente nella striscia', () => {
|
it('dovrebbe conservare il set precedente nella striscia', () => {
|
||||||
state.sp.striscia[0].r = ['home', 'guest', 'home']
|
state.sp.striscia[0].ris = 'hgh'
|
||||||
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
|
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
|
||||||
expect(s.sp.striscia[0].r).toEqual(['home', 'guest', 'home'])
|
expect(s.sp.striscia[0].ris).toBe('hgh')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dovrebbe resettare le formazioni', () => {
|
it('dovrebbe resettare le formazioni', () => {
|
||||||
@@ -271,6 +271,66 @@ describe('Game Logic (gameState.js)', () => {
|
|||||||
expect(s.sp.set.home).toBe(0)
|
expect(s.sp.set.home).toBe(0)
|
||||||
expect(s.sp.set.guest).toBe(0)
|
expect(s.sp.set.guest).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('in 2/3 dovrebbe registrare il set vincente senza resettare il punteggio', () => {
|
||||||
|
state.modalitaPartita = '2/3'
|
||||||
|
state.sp.set.home = 1
|
||||||
|
state.sp.punt.home = 25
|
||||||
|
state.sp.punt.guest = 18
|
||||||
|
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
|
||||||
|
expect(s.sp.set.home).toBe(2)
|
||||||
|
expect(s.sp.punt.home).toBe(25)
|
||||||
|
expect(s.sp.punt.guest).toBe(18)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('in 3/5 dovrebbe registrare il set vincente senza resettare il punteggio', () => {
|
||||||
|
state.modalitaPartita = '3/5'
|
||||||
|
state.sp.set.home = 2
|
||||||
|
state.sp.punt.home = 25
|
||||||
|
state.sp.punt.guest = 20
|
||||||
|
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
|
||||||
|
expect(s.sp.set.home).toBe(3)
|
||||||
|
expect(s.sp.punt.home).toBe(25)
|
||||||
|
expect(s.sp.punt.guest).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dovrebbe ignorare nuovoSet se la partita è già finita', () => {
|
||||||
|
state.modalitaPartita = '2/3'
|
||||||
|
state.sp.set.home = 2
|
||||||
|
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
|
||||||
|
expect(s.sp.set.home).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// VITTORIA PARTITA (checkVittoriaPartita)
|
||||||
|
// =============================================
|
||||||
|
describe('checkVittoriaPartita', () => {
|
||||||
|
it('in 2/3 restituisce false se nessuno ha 2 set', () => {
|
||||||
|
state.modalitaPartita = '2/3'
|
||||||
|
state.sp.set.home = 1
|
||||||
|
state.sp.set.guest = 0
|
||||||
|
expect(checkVittoriaPartita(state)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('in 2/3 restituisce true se home ha 2 set', () => {
|
||||||
|
state.modalitaPartita = '2/3'
|
||||||
|
state.sp.set.home = 2
|
||||||
|
expect(checkVittoriaPartita(state)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('in 3/5 restituisce false se nessuno ha 3 set', () => {
|
||||||
|
state.modalitaPartita = '3/5'
|
||||||
|
state.sp.set.home = 2
|
||||||
|
state.sp.set.guest = 2
|
||||||
|
expect(checkVittoriaPartita(state)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('in 3/5 restituisce true se guest ha 3 set', () => {
|
||||||
|
state.modalitaPartita = '3/5'
|
||||||
|
state.sp.set.guest = 3
|
||||||
|
expect(checkVittoriaPartita(state)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -661,10 +721,10 @@ describe('Game Logic (gameState.js)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('dovrebbe resettare la striscia a un set vuoto', () => {
|
it('dovrebbe resettare la striscia a un set vuoto', () => {
|
||||||
state.sp.striscia = [{ serv: 'home', r: ['home', 'guest', 'home'] }, { serv: 'home', r: ['guest'] }]
|
state.sp.striscia = [{ serv: 'h', ris: 'hgh' }, { serv: 'h', ris: 'g' }]
|
||||||
const s = applyAction(state, { type: 'resetta' })
|
const s = applyAction(state, { type: 'resetta' })
|
||||||
expect(s.sp.striscia).toHaveLength(1)
|
expect(s.sp.striscia).toHaveLength(1)
|
||||||
expect(s.sp.striscia[0].r).toEqual([])
|
expect(s.sp.striscia[0].ris).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dovrebbe impostare visuForm a false', () => {
|
it('dovrebbe impostare visuForm a false', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user