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

12 KiB
Raw Permalink Blame History

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

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

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

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)