Every game over unlocks the profile and frees the name, so the menu always asks for a new player name: the field is empty at every boot, restart or game over and the game does not start without a name (no anonymous scores). The player identity is derived from the entered name (deterministic FNV-1a, 16 hex chars valid for the public API) and the profile keeps the best score per name. Backend untouched. Mouse: player._ready() captured the pointer before the menu existed, so the start screen was not clickable. The cursor now always starts visible; the capture happens only in player.begin_play(), called by start_game(). New `playing` flag gates Esc and the Web click capture to the actual match. Linux export preset: single-file x86_64 binary with the PCK embedded, no libstdc++ dependency, official 4.7.1 template (glibc >= 2.28). Notes and the export_presets.cfg `#`-is-not-a-comment trap documented in docs/KIOSK-ARCADE.md. Tests: profile 30/30, start screen 15/15 (cursor free at menu, captured only in game), boss 50/50, rebalance 14/14, touch 14 PASS, API 42/42, all other suites pass.
227 lines
12 KiB
Markdown
227 lines
12 KiB
Markdown
# 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);
|
||
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 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 | una build desktop ha inviato senza `--api=`: verificare `leaderboard.disabled` (test `test_start_screen.gd`) |
|