diff --git a/README.md b/README.md index c806df7..fd2a453 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,68 @@ Espone i due server: --- +## Terminal Controller (CLI) + +Il CLI è un controller da terminale che si connette al server via WebSocket e permette di gestire la partita senza browser. + +### Avvio + +```bash +# Modalità produzione (server su porta 3000) +npm run cli + +# Modalità sviluppo (server Vite su porta 5173) +npm run cli:dev + +# Porta custom +node cli.js +``` + +Il CLI richiede che il server sia già in esecuzione in un altro terminale. + +### Comandi disponibili + +#### Punteggio + +| Comando | Alias | Effetto | +|---------|-------|---------| +| `punto casa` | `+`, `pc` | Assegna un punto alla squadra di casa | +| `punto ospite` | `-`, `po` | Assegna un punto alla squadra ospite | +| `undo` | `u` | Annulla l'ultimo punto assegnato | + +#### Set + +| Comando | Effetto | +|---------|---------| +| `set casa` | Incrementa il contatore set della squadra di casa | +| `set ospite` | Incrementa il contatore set della squadra ospite | + +#### Partita + +| Comando | Effetto | +|---------|---------| +| `serv` | Cambia il servizio (disponibile solo se il punteggio è 0-0) | +| `reset` | Resetta la partita — chiede conferma prima di procedere | +| `nomi ` | Imposta i nomi delle squadre (es. `nomi Antoniana Teate`) | +| `modalita 2/3` | Imposta la modalità best-of-3 | +| `modalita 3/5` | Imposta la modalità best-of-5 | + +#### Informazioni + +| Comando | Alias | Effetto | +|---------|-------|---------| +| `stato` | — | Mostra il punteggio corrente nel terminale | +| `help` | — | Mostra la lista dei comandi | +| `exit` | `q` | Chiude il CLI | + +### Note + +- **Tab**: completamento automatico dei comandi +- **Freccia su/giù**: navigazione nella history dei comandi (ultime 100 voci) +- Il Display nel browser si aggiorna in tempo reale ad ogni comando inviato + +--- + ## Test La suite di test copre tutti i livelli dell'applicazione: diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..c325608 --- /dev/null +++ b/cli.js @@ -0,0 +1,302 @@ +import { WebSocket } from 'ws'; +import readline from 'readline'; + +// --------------------------------------------------------------------------- +// ANSI helpers +// --------------------------------------------------------------------------- + +const c = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + brightWhite: '\x1b[97m', +}; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const port = process.argv[2] || process.env.PORT || 3000; +const url = `ws://localhost:${port}/ws`; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let currentState = null; +let connected = false; + +// --------------------------------------------------------------------------- +// Startup banner +// --------------------------------------------------------------------------- + +console.log(`\n${c.bold}${c.cyan} Segnapunti Anto — Terminal Controller${c.reset}`); +console.log(`${c.dim} Connessione a ${url}...${c.reset}\n`); + +// --------------------------------------------------------------------------- +// WebSocket +// --------------------------------------------------------------------------- + +const ws = new WebSocket(url); + +ws.on('error', (err) => { + console.error(`${c.red}Errore di connessione: ${err.message}${c.reset}`); + console.error(`${c.dim}Assicurati che il server sia avviato su ${url}${c.reset}`); + process.exit(1); +}); + +ws.on('open', () => { + connected = true; + console.log(` ${c.green}Connesso.${c.reset} Digita ${c.bold}help${c.reset} per i comandi disponibili.\n`); + ws.send(JSON.stringify({ type: 'register', role: 'controller' })); +}); + +ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'state') { + currentState = msg.state; + printState(currentState); + } else if (msg.type === 'error') { + clearLine(); + console.error(` ${c.red}Errore server: ${msg.message}${c.reset}`); + } + } catch { + // ignora messaggi malformati + } + rl.prompt(true); +}); + +ws.on('close', () => { + console.log(`\n${c.dim} Connessione chiusa.${c.reset}\n`); + process.exit(0); +}); + +// --------------------------------------------------------------------------- +// Output helpers +// --------------------------------------------------------------------------- + +/** Cancella la riga corrente del terminale (evita di sovrascrivere il prompt). */ +function clearLine() { + process.stdout.write('\r\x1b[K'); +} + +function printState(s) { + if (!s) return; + const { nomi, punt, set, servHome } = s.sp; + + const homeServ = servHome ? `${c.yellow}▶${c.reset}` : ' '; + const guestServ = !servHome ? `${c.yellow}◀${c.reset}` : ' '; + + const homeName = nomi.home.padEnd(15); + const guestName = nomi.guest.padEnd(15); + const homeScore = String(punt.home).padStart(3); + const guestScore = String(punt.guest).padStart(3); + + clearLine(); + console.log( + ` ${homeServ} ${c.bold}${homeName}${c.reset}` + + `${c.brightWhite}${homeScore}${c.reset} ${c.dim}(set ${set.home})${c.reset}` + + ` ${c.dim}│${c.reset} ` + + `${c.brightWhite}${guestScore}${c.reset} ${c.dim}(set ${set.guest})${c.reset}` + + ` ${c.bold}${guestName}${c.reset} ${guestServ}` + + ` ${c.dim}[${s.modalitaPartita}]${c.reset}` + ); +} + +function printHelp() { + console.log(` +${c.bold} Punteggio${c.reset} + ${c.cyan}+${c.reset} / ${c.cyan}pc${c.reset} Punto casa (shortcut) + ${c.cyan}-${c.reset} / ${c.cyan}po${c.reset} Punto ospite (shortcut) + ${c.cyan}punto casa${c.reset} Punto casa + ${c.cyan}punto ospite${c.reset} Punto ospite + ${c.cyan}undo${c.reset} / ${c.cyan}u${c.reset} Annulla ultimo punto + +${c.bold} Set${c.reset} + ${c.cyan}set casa${c.reset} Incrementa set casa + ${c.cyan}set ospite${c.reset} Incrementa set ospite + +${c.bold} Partita${c.reset} + ${c.cyan}serv${c.reset} Cambia servizio (solo se 0-0) + ${c.cyan}reset${c.reset} Resetta la partita (chiede conferma) + ${c.cyan}nomi ${c.reset} Imposta nomi squadre + ${c.cyan}modalita 2/3${c.reset} Imposta modalità best-of-3 + ${c.cyan}modalita 3/5${c.reset} Imposta modalità best-of-5 + +${c.bold} Informazioni${c.reset} + ${c.cyan}stato${c.reset} Mostra punteggio attuale + ${c.cyan}help${c.reset} Mostra questo aiuto + ${c.cyan}exit${c.reset} / ${c.cyan}q${c.reset} Esci + + ${c.dim}Suggerimento: usa Tab per il completamento automatico dei comandi.${c.reset} +`); +} + +// --------------------------------------------------------------------------- +// Command dispatch +// --------------------------------------------------------------------------- + +function sendAction(action) { + if (!connected || ws.readyState !== WebSocket.OPEN) { + console.error(` ${c.red}Non connesso al server.${c.reset}`); + rl.prompt(); + return; + } + ws.send(JSON.stringify({ type: 'action', action })); +} + +function parseCommand(line) { + const parts = line.trim().split(/\s+/); + const cmd = parts[0].toLowerCase(); + + switch (cmd) { + + case '+': + case 'pc': + sendAction({ type: 'incPunt', team: 'home' }); + break; + + case '-': + case 'po': + sendAction({ type: 'incPunt', team: 'guest' }); + break; + + case 'punto': { + const team = parts[1]?.toLowerCase(); + if (team === 'casa' || team === 'home') { + sendAction({ type: 'incPunt', team: 'home' }); + } else if (team === 'ospite' || team === 'guest') { + sendAction({ type: 'incPunt', team: 'guest' }); + } else { + console.error(` ${c.red}Uso: punto casa | punto ospite${c.reset}`); + rl.prompt(); + } + break; + } + + case 'undo': + case 'u': + sendAction({ type: 'decPunt' }); + break; + + case 'set': { + const team = parts[1]?.toLowerCase(); + if (team === 'casa' || team === 'home') { + sendAction({ type: 'incSet', team: 'home' }); + } else if (team === 'ospite' || team === 'guest') { + sendAction({ type: 'incSet', team: 'guest' }); + } else { + console.error(` ${c.red}Uso: set casa | set ospite${c.reset}`); + rl.prompt(); + } + break; + } + + case 'serv': + sendAction({ type: 'cambiaPalla' }); + break; + + case 'reset': + rl.question(` ${c.yellow}Confermi reset partita? (s/N) ${c.reset}`, (answer) => { + if (answer.trim().toLowerCase() === 's') { + sendAction({ type: 'resetta' }); + } else { + console.log(` ${c.dim}Reset annullato.${c.reset}`); + rl.prompt(); + } + }); + return; + + case 'nomi': { + const home = parts[1]; + const guest = parts[2]; + if (!home) { + console.error(` ${c.red}Uso: nomi ${c.reset}`); + rl.prompt(); + break; + } + const payload = { type: 'setNomi', home }; + if (guest) payload.guest = guest; + sendAction(payload); + break; + } + + case 'modalita': { + const m = parts[1]; + if (m !== '2/3' && m !== '3/5') { + console.error(` ${c.red}Uso: modalita 2/3 | modalita 3/5${c.reset}`); + rl.prompt(); + break; + } + sendAction({ type: 'setModalita', modalita: m }); + break; + } + + case 'stato': + printState(currentState); + rl.prompt(); + break; + + case 'help': + printHelp(); + rl.prompt(); + break; + + case 'exit': + case 'q': + ws.close(); + break; + + default: + console.error( + ` ${c.red}Comando non riconosciuto: "${cmd}"${c.reset}` + + ` — digita ${c.bold}help${c.reset} per la lista` + ); + rl.prompt(); + } +} + +// --------------------------------------------------------------------------- +// REPL +// --------------------------------------------------------------------------- + +const TAB_COMPLETIONS = [ + '+', '-', 'pc', 'po', + 'punto casa', 'punto ospite', + 'undo', 'u', + 'set casa', 'set ospite', + 'serv', + 'reset', + 'nomi', + 'modalita 2/3', 'modalita 3/5', + 'stato', 'help', + 'exit', 'q', +]; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: '> ', + historySize: 100, + completer(line) { + const hits = TAB_COMPLETIONS.filter((entry) => entry.startsWith(line)); + return [hits.length ? hits : TAB_COMPLETIONS, line]; + }, +}); + +rl.on('line', (line) => { + if (!line.trim()) { + rl.prompt(); + return; + } + parseCommand(line.trim()); +}); + +rl.on('close', () => { + ws.close(); +}); diff --git a/package.json b/package.json index abe3f42..e49fc9d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "preview": "node server.js", "start": "node server.js", "serve": "vite build && node server.js", + "cli": "node cli.js", + "cli:dev": "node cli.js 5173", "test": "vitest", "test:unit": "vitest run tests/unit tests/integration", "test:component": "vitest run tests/component",