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.
12 KiB
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>
limit1–50 (predefinito 10).player(facoltativo) aggiunge il riassunto personaleme: {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_idrisponde200con"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.0s,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 →413e connessione chiusa. - CORS disattivato in produzione (
DEV_CORS_ORIGINSserve 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()):- argomento
--api=http://host:porta(test); - su Web:
window.location.origin + /api/zombie-fps; - 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 righeTest).
- argomento
- Due nodi
HTTPRequestseparati (lettura e invio non si sovrappongono); timeout 12 s,body_size_limit256 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 (guardiascore_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
localStorageperché è 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
lockedarrivato 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 valorinull,undefinede 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()→ segnalestarted→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 | una build desktop ha inviato senza --api=: verificare leaderboard.disabled (test test_start_screen.gd) |