# 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=` * `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": "", "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`) |