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

11 KiB
Raw Blame History

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

{"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):

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):

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

# 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:

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

# 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:

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)