Files
zombie-fps/docs/HIGHSCORES.md
T
enne2 ae32ad9615 Classifica globale: API dei punteggi, schermata iniziale e nome memorizzato
- services/scores-api: microservizio (solo stdlib) con SQLite WAL, idempotenza per
  submission_id, limiti di frequenza e validazione; immagine Docker non-root.
- nginx: reverse proxy su /api/zombie-fps/ (stessa origine del gioco, niente CORS).
- scripts/leaderboard.gd: client HTTP con retry idempotente; su desktop la classifica
  resta disattivata senza --api=, cosi' i test non sporcano la classifica pubblica.
- scripts/player_profile.gd: nome, id anonimo e miglior punteggio in user:// e in
  window.localStorage; il nome scelto non viene piu' richiesto; riparazione dei
  profili con la stringa "null".
- scripts/start_screen.gd: menu con nome, GIOCA e classifica; la partita parte solo
  dal pulsante (via la rete di sicurezza sull'input, Invio nel campo non avvia).
- scripts/main.gd, player.gd: avvio esplicito, segnale died, nota dell'invio.
- tools: test_scores_api.py (42), test_leaderboard (14), test_player_profile (21),
  test_start_screen (10); suite esistenti adeguate all'avvio manuale.
- docs/HIGHSCORES.md + docs/WEB-DEPLOY.md aggiornati.
2026-10-13 23:26:00 +02:00

222 lines
11 KiB
Markdown
Raw 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) — Zombie Room FPS
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 scelto, id giocatore, miglior punteggio |
| `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` | 21 controlli sul profilo (nome, id, riparazioni) |
| `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);
2. su Web: `window.location.origin + /api/zombie-fps`;
3. altrimenti **niente**: la classifica resta disattivata con
`DISABLED_REASON = "classifica non attiva in questa build"`.
Regola importante: una build desktop di prova **non deve** poter inviare punteggi
alla classifica pubblica (è già successo: ci sono finite righe `Test`).
* 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 scelto |
| `zombie_fps_player_id` | id anonimo, 32 caratteri esadecimali |
| `zombie_fps_best` | miglior punteggio locale |
| `zombie_fps_locked` | `1` se il nome è già stato scelto |
* 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.
* **Il nome, una volta scelto, non viene più richiesto**: se `locked` è attivo il menu
mostra `giocatore: Mat` e il campo di testo non esiste proprio.
* Un nome diverso da quello predefinito (`Giocatore`) vale come nome scelto, anche per
i profili salvati dalle versioni precedenti.
* **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 # 21 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 # 10 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 | una build desktop ha inviato senza `--api=`: verificare `leaderboard.disabled` (test `test_start_screen.gd`) |