Files
zombie-fps/docs/HIGHSCORES.md
T
enne2 cb14fd5400 Arcade/kiosk name flow, free cursor in menu and portable Linux export
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.
2026-10-24 12:50:03 +02:00

227 lines
12 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) — 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`) |