Files
Matteo Benedetto 323a7cdbae Leaderboard attiva di default, senza configurazione
La classifica globale non dipende più da un argomento: sui build desktop il client
usa il servizio pubblico DEFAULT_API_BASE (https://enne2.net/api/zombie-fps).

- scripts/leaderboard.gd: resolve_base() statica con priorità --api=<url> (test)
  → --offline/--no-api (opt-out esplicito per le partite di prova) → origine della
  pagina sul Web → servizio pubblico. Rimosso l'avviso "Classifica disattivata".
- Le suite di test (--script) restano spente a meno di un --api= esplicito, così
  nessun test può scrivere nella classifica pubblica.
- tools/test_start_screen.gd: verifica default attivo, opt-out e override;
  tools/test_rebalance_branding.gd: suite di test senza invii.
- docs/HIGHSCORES.md: nuova regola della base URL e diagnostica aggiornata.

Verifiche: test_start_screen 18/18 PASS con display; test_rebalance_branding
14/14 PASS; test_leaderboard.sh invariato (12 PASS, 2 FAIL preesistenti su questa
macchina anche prima della modifica).
2026-10-24 15:57:42 +02:00

231 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Classifica globale (punteggi) — Work-Death Balance
Sistema di punteggi online: schermata iniziale con classifica e nome del giocatore,
microservizio sul bastion, invio del punteggio alla morte.
## 1. Architettura
```
browser ─── https://enne2.net/pub/zombie-fps/ → nginx → file statici (release versionate)
└── https://enne2.net/api/zombie-fps/... → nginx (reverse proxy) → 127.0.0.1:8791
container Docker "zombie-fps-scores"
SQLite /opt/zombie-fps-scores/data/scores.db
```
* **Stessa origine**: gioco e API stanno sotto lo stesso host → il browser non ha
bisogno di CORS. Il servizio ascolta **solo** su `127.0.0.1:8791`, l'unico ingresso
pubblico è nginx.
* Il prefisso `/api/zombie-fps/` viene **rimosso** dal proxy prima di inoltrare
(il container serve le rotte senza prefisso: `/health`, `/scores`).
File coinvolti:
| File | Ruolo |
| --- | --- |
| `services/scores-api/app.py` | servizio API (solo libreria standard: `ThreadingHTTPServer` + `sqlite3`) |
| `services/scores-api/Dockerfile` | immagine `python:3.12-alpine`, utente non-root, healthcheck |
| `services/scores-api/docker-compose.yml` | compose (volume, limiti, log rotation) |
| `tools/nginx-zombie-fps-api.conf` | snippet nginx del reverse proxy |
| `scripts/leaderboard.gd` | client HTTP Godot (lettura + invio, retry) |
| `scripts/player_profile.gd` | profilo locale: nome dell'ultima partita, id giocatore, miglior punteggio per nome |
| `scripts/start_screen.gd` | menu: nome, pulsante GIOCA, classifica |
| `tools/test_scores_api.py` | 42 controlli sul servizio (funzionalità, validazione, limiti) |
| `tools/test_leaderboard.gd` + `.sh` | 14 controlli sul client, contro un'API locale usa e getta |
| `tools/test_player_profile.gd` | 30 controlli sul profilo (campo del nome sempre libero, id derivato dal nome, record per nome, riparazioni, migrazione dopo rinomina) |
| `tools/test_start_screen.gd` | 10 controlli sulle regole del menu |
## 2. Contratto dell'API
### `GET /health`
`{"ok": true, "service": "zombie-fps-scores", "version": "1.0.0", "rows": 14}`
### `GET /scores?limit=10&player=<player_id>`
* `limit` 1–50 (predefinito 10).
* `player` (facoltativo) aggiunge il riassunto personale `me: {best, rank, games}`.
* Risposta: `{"top": [{rank, id, player_id, name, score, created_at}, ...], "total": N, "limit": L, "me": {...}|null}`.
### `POST /scores`
```json
{"player_id": "024a956d532357c426ceb08ff86124e1", "name": "Mat", "score": 800,
"submission_id": "0f6c1d4e-…-…"}
```
Risposta `200`: `{"ok": true, "duplicate": false, "id": "<uuid>", "rank": 1, "total": 8}`.
Regole di validazione (risposta `400` con `{"ok": false, "error": "..."}`):
| Campo | Regola |
| --- | --- |
| `name` | 1–16 caratteri, `^[\w \-.]{1,16}$` (lettere anche accentate, cifre, spazio, `-`, `.`) |
| `player_id` | 8–64 caratteri `[A-Za-z0-9_-]` |
| `submission_id` | uuid valido, **unico** (idempotenza) |
| `score` | intero 0–200000, **multiplo di 50** (i booleani sono rifiutati) |
* **Idempotenza**: lo stesso `submission_id` risponde `200` con `"duplicate": true`.
Il client lo riusa nei retry, quindi una risposta persa non crea punteggi doppi.
* **Limiti di frequenza** (per IP reale, via `X-Real-IP`/`X-Forwarded-For`):
`POST_PER_HOUR=20`, `POST_MIN_INTERVAL=5.0` s, `GET_PER_MINUTE=120`.
Una richiesta **rifiutata non consuma** la quota dell'IP.
Il controllo di duplicato viene **prima** dei limiti: un retry legittimo non è mai bloccato.
* Corpo massimo 2 KB (`MAX_BODY_BYTES`); oltre → `413` e connessione chiusa.
* CORS disattivato in produzione (`DEV_CORS_ORIGINS` serve solo allo sviluppo).
### Semantica della classifica
**Tutte le partite** contano: ogni invio valido è una riga e viene classificato (non
solo il migliore per giocatore). Il riassunto `me` dello stesso giocatore tiene il
miglior punteggio e il numero di partite.
## 3. Dati
Schema SQLite (WAL, `synchronous=FULL`, `busy_timeout`):
```sql
CREATE TABLE scores (
id TEXT PRIMARY KEY, -- uuid4 generato dal server
player_id TEXT NOT NULL, -- id anonimo del giocatore (dal client)
submission_id TEXT NOT NULL UNIQUE, -- idempotenza
name TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL
);
```
Database: `/opt/zombie-fps-scores/data/scores.db` (+ `-wal`, `-shm`: si monta la
**directory**, non il singolo file). Il processo nel container gira con l'uid 1000
(`user: "1000:1000"` nel compose) così i file restano dell'utente `debian` e si
possono leggere e copiare senza `sudo`.
Copia di sicurezza a caldo (WAL):
```bash
docker exec zombie-fps-scores python -c "
import sqlite3
s = sqlite3.connect('/data/scores.db'); d = sqlite3.connect('/data/backup.db')
s.backup(d); d.close(); s.close()"
```
## 4. Distribuzione sul bastion
```bash
# servizio (una tantum) — la cartella la crea l'utente, non root
sudo mkdir -p /opt/zombie-fps-scores/data && sudo chown -R debian:debian /opt/zombie-fps-scores
tar -C services/scores-api -cf - app.py Dockerfile docker-compose.yml .dockerignore \
| ssh debian@enne2.net 'tar -C /opt/zombie-fps-scores -xf -'
ssh debian@enne2.net 'cd /opt/zombie-fps-scores && docker compose up -d --build'
curl -s http://127.0.0.1:8791/health # verifica locale
```
nginx: `tools/nginx-zombie-fps-api.conf` → `/etc/nginx/snippets/zombie-fps-api.conf`,
più l'`include` **dentro il server block di `enne2.net`**
(`/etc/nginx/conf.d/enne2.conf`, subito dopo quello del gioco); poi **sempre**:
```bash
sudo cp /etc/nginx/conf.d/enne2.conf /etc/nginx/conf.d/enne2.conf.bak-$(date +%Y%m%d-%H%M)
sudo nginx -t && sudo systemctl reload nginx # se -t fallisce: ripristinare il backup e non ricaricare
```
## 5. Client Godot
* **Base URL** (`scripts/leaderboard.gd`, `resolve_base()`):
1. argomento `--api=http://host:porta` (test o servizio alternativo);
2. argomento `--offline` / `--no-api` → classifica **spenta** (partite di prova);
3. su Web: `window.location.origin + /api/zombie-fps`;
4. altrimenti il servizio pubblico `DEFAULT_API_BASE =
https://enne2.net/api/zombie-fps`.
La classifica è quindi **attiva di default**: non serve nesuna configurazione né
argomento per leggere e inviare punteggi. L'opt-out esiste solo per le partite di
prova, che non devono finire nella classifica pubblica (è già successo: ci sono
finite righe `Test`). Le suite di test girano con `--script` e restano spente a
meno di un `--api=` esplicito.
* Due nodi `HTTPRequest` separati (lettura e invio non si sovrappongono); timeout 12 s,
`body_size_limit` 256 KB / 64 KB.
* Retry su 429/5xx/errore di trasporto: fino a 3 tentativi, 6 s di pausa, **stesso
`submission_id`** (idempotente). Una lettura chiesta mentre la precedente è in volo
viene rimandata (`_fetch_again`), non genera errore.
* Alla morte: `player.died` → `main.gd::_on_player_died()` → aggiorna il miglior
punteggio locale, scrive "invio del punteggio in corso…", invia una sola volta
(guardia `score_submitted`); la risposta diventa "PUNTEGGIO 800 — 1° su 8 partite".
## 6. Identità e nome del giocatore
`scripts/player_profile.gd` — file `user://zombie_fps_profile.cfg` **e**, sul Web,
`window.localStorage`:
| Chiave | Contenuto |
| --- | --- |
| `zombie_fps_name` | nome dell'ultima partita (solo come chiave del suo record) |
| `zombie_fps_player_id` | id anonimo, 32 caratteri esadecimali, o 16 derivato dal nome |
| `zombie_fps_best` | miglior punteggio del nome salvato |
| `bests` (sezione del file `.cfg`) | miglior punteggio **per nome** |
| `zombie_fps_locked` | sempre `0` in modalità arcade/kiosk |
* Sul Web si scrive **anche** in `localStorage` perché è la memoria del browser che
sopravvive davvero alla ricarica della pagina (l'IndexedDB di Godot può non essere
sincronizzata). Alla lettura i due archivi si fondono: l'id del giocatore resta
stabile, così i punteggi vecchi restano collegati.
* **Modalità arcade/kiosk (`ALWAYS_ASK_NAME`)**: il nome non viene mai dato per scontato.
Il campo del menu è sempre presente e arriva vuoto a ogni avvio, riavvio o game over;
senza nome scritto la partita non parte (nessun punteggio anonimo in classifica).
Il profilo conserva solo il **miglior punteggio per nome**. Dettagli: `docs/KIOSK-ARCADE.md`.
* L'identità della partita nasce dal nome inserito: `player_id_for_name()` è un FNV-1a
deterministico (16 caratteri esadecimali, valido per il server). Stesso nome = stesso id
anche dopo un riavvio del kiosk, nomi diversi = identità diverse. Il backend non cambia.
* Un `locked` arrivato da profili o dal browser di versioni precedenti viene ignorato.
* **Riparazione automatica**: `str(null)` in GDScript produce la stringa `"null"`, e
una versione precedente l'ha salvata come nome. Alla lettura i valori `null`,
`undefined` e vuoti vengono scartati, il nome torna al predefinito e il blocco si
scioglie (il punteggio migliore e l'id vengono conservati).
* Il nome è ripulito come fa il server (max 16 caratteri, via apici/emoji/simboli che
provocherebbero un `400`): lettere anche accentate, cifre, spazio, `-`, `.`.
## 7. Regole della schermata iniziale
* **La partita parte solo dal pulsante GIOCA** (`scripts/start_screen.gd::_start()` →
segnale `started` → `main.gd::_on_start_pressed`). Non esiste più nessuna rete di
sicurezza sull'input: click o tocco sullo sfondo, clic sul pannello della classifica
e Invio/"Vai" nel campo del nome **non** avviano niente (su mobile il tasto "Vai"
della tastiera faceva partire la partita per sbaglio). L'Invio nel campo si limita a
normalizzare il nome.
* Il pulsante è anche il gesto che sblocca l'audio nella pagina (policy dei browser).
* Il menu legge la classifica all'avvio; "aggiorna" la rilegge su richiesta.
## 8. Limite dichiarato (anti-cheat)
I punteggi sono calcolati **dal client** e quindi falsificabili: non c'è autenticazione
né verifica lato server. Le uniche mitigazioni sono limiti di frequenza, tetti di
plausibilità (multiplo di 50, ≤ 200000), idempotenza e nome validato. Per una classifica
affidabile servirebbe una validazione server-side della partita (es. replay o
attestazione), che questo gioco non ha.
## 9. Verifiche
```bash
# servizio (usa python3 locale, database temporaneo)
python3 tools/test_scores_api.py # 42 controlli
# client, contro un'API locale avviata per l'occasione
sh tools/test_leaderboard.sh # 14 controlli
# profilo e menu (menu: serve un display)
$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64 --headless --path . \
--script res://tools/test_player_profile.gd # 30 controlli
$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64 --path . \
--rendering-method gl_compatibility --script res://tools/test_start_screen.gd # 11 controlli
```
In produzione si verifica a mano, con `curl`:
```bash
curl -s https://enne2.net/api/zombie-fps/health
curl -s "https://enne2.net/api/zombie-fps/scores?limit=5"
```
## 10. Diagnostica
| Sintomo | Causa / rimedio |
| --- | --- |
| container in `Restarting`, log `unable to open database file` | la cartella montata non è scrivibile dall'utente del container: `user: "1000:1000"` nel compose e `chown` coerente su `./data` |
| `classifica non disponibile (rete non disponibile)` nel menu | servizio spento o nginx non ricaricato: `docker ps`, `curl 127.0.0.1:8791/health`, `sudo nginx -t` |
| punteggio non inviato (`429`) | limite di frequenza per IP (20/ora, 5 s tra invii); i retry riusano lo stesso `submission_id`, quindi non raddoppiano |
| la pagina resta su una release vecchia | `index.html` è servito con `cache-control: no-cache`; forzare il ricaricamento della pagina |
| righe di prova nella classifica pubblica | la classifica è attiva di default: le partite di prova vanno lanciate con `-- --offline` (verificare `leaderboard.disabled`, test `test_start_screen.gd`) |