From ae32ad96151fb0f78ce640b107ca797948d9c852 Mon Sep 17 00:00:00 2001 From: enne2 Date: Tue, 13 Oct 2026 23:26:00 +0200 Subject: [PATCH] Classifica globale: API dei punteggi, schermata iniziale e nome memorizzato - services/scores-api: microservizio (solo stdlib) con SQLite WAL, idempotenza per submission_id, limiti di frequenza e validazione; immagine Docker non-root. - nginx: reverse proxy su /api/zombie-fps/ (stessa origine del gioco, niente CORS). - scripts/leaderboard.gd: client HTTP con retry idempotente; su desktop la classifica resta disattivata senza --api=, cosi' i test non sporcano la classifica pubblica. - scripts/player_profile.gd: nome, id anonimo e miglior punteggio in user:// e in window.localStorage; il nome scelto non viene piu' richiesto; riparazione dei profili con la stringa "null". - scripts/start_screen.gd: menu con nome, GIOCA e classifica; la partita parte solo dal pulsante (via la rete di sicurezza sull'input, Invio nel campo non avvia). - scripts/main.gd, player.gd: avvio esplicito, segnale died, nota dell'invio. - tools: test_scores_api.py (42), test_leaderboard (14), test_player_profile (21), test_start_screen (10); suite esistenti adeguate all'avvio manuale. - docs/HIGHSCORES.md + docs/WEB-DEPLOY.md aggiornati. --- docs/HIGHSCORES.md | 221 ++++++++++++++++ docs/WEB-DEPLOY.md | 17 ++ export_presets.cfg | 1 + scripts/leaderboard.gd | 170 +++++++++++++ scripts/leaderboard.gd.uid | 1 + scripts/main.gd | 106 +++++--- scripts/player.gd | 23 ++ scripts/player_profile.gd | 207 +++++++++++++++ scripts/player_profile.gd.uid | 1 + scripts/start_screen.gd | 198 +++++++++++++++ scripts/start_screen.gd.uid | 1 + services/scores-api/.dockerignore | 3 + services/scores-api/Dockerfile | 18 ++ services/scores-api/app.py | 337 +++++++++++++++++++++++++ services/scores-api/docker-compose.yml | 35 +++ tools/nginx-zombie-fps-api.conf | 24 ++ tools/test_death_effects.gd | 1 + tools/test_leaderboard.gd | 150 +++++++++++ tools/test_leaderboard.gd.uid | 1 + tools/test_leaderboard.sh | 16 ++ tools/test_music.gd | 1 + tools/test_player_profile.gd | 144 +++++++++++ tools/test_player_profile.gd.uid | 1 + tools/test_scores_api.py | 263 +++++++++++++++++++ tools/test_start_screen.gd | 161 ++++++++++++ tools/test_start_screen.gd.uid | 1 + tools/test_wave_growth.gd | 1 + 27 files changed, 2071 insertions(+), 32 deletions(-) create mode 100644 docs/HIGHSCORES.md create mode 100644 scripts/leaderboard.gd create mode 100644 scripts/leaderboard.gd.uid create mode 100644 scripts/player_profile.gd create mode 100644 scripts/player_profile.gd.uid create mode 100644 scripts/start_screen.gd create mode 100644 scripts/start_screen.gd.uid create mode 100644 services/scores-api/.dockerignore create mode 100644 services/scores-api/Dockerfile create mode 100644 services/scores-api/app.py create mode 100644 services/scores-api/docker-compose.yml create mode 100644 tools/nginx-zombie-fps-api.conf create mode 100644 tools/test_leaderboard.gd create mode 100644 tools/test_leaderboard.gd.uid create mode 100755 tools/test_leaderboard.sh create mode 100644 tools/test_player_profile.gd create mode 100644 tools/test_player_profile.gd.uid create mode 100644 tools/test_scores_api.py create mode 100644 tools/test_start_screen.gd create mode 100644 tools/test_start_screen.gd.uid diff --git a/docs/HIGHSCORES.md b/docs/HIGHSCORES.md new file mode 100644 index 0000000..c32e1dc --- /dev/null +++ b/docs/HIGHSCORES.md @@ -0,0 +1,221 @@ +# 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`) | diff --git a/docs/WEB-DEPLOY.md b/docs/WEB-DEPLOY.md index d79677b..44e2d51 100644 --- a/docs/WEB-DEPLOY.md +++ b/docs/WEB-DEPLOY.md @@ -1,5 +1,22 @@ # Web build and compressed deployment +## Global high scores release: a961c96175bc644e + +Start screen with global leaderboard, player name and score submission; new score API +service on the bastion behind an nginx reverse proxy, see `docs/HIGHSCORES.md`. +Backups on the root: `index-before-highscores.html`, `index-before-menu-fix.html`, +`index-before-null-fix.html`, `index-before-repair.html`, `index-before-lb-guard.html`. + +Releases kept on the server (retention policy: live + previous): +`a961c96175bc644e` (live) and `209fa6b154fea8df`. Nine older releases were removed +with the guarded procedure (604 MB -> 110 MB, 4.6 GB free, deleted release 404, +live PCK `cmp`-identical, previous release still 200). + +Intermediate builds of this same work, all superseded and now deleted: +`dcf1260b1a673ef4` (first leaderboard build), `6e012ca9a54d7baa` (menu fix, stored the +string "null" as name), `69a88b5cca18f009` (name repair, no leaderboard guard), +`209fa6b154fea8df` (previous release, kept). + ## Eight windows release: fbfc9bdc01e2b32d Two windows per wall (eight total) and random zombie entry from different openings, see diff --git a/export_presets.cfg b/export_presets.cfg index ce67398..95b16b9 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -26,4 +26,5 @@ html/custom_html_shell="" html/head_include="" html/canvas_resize_policy=2 html/focus_canvas_on_start=true +html/experimental_virtual_keyboard=true progressive_web_app/enabled=false diff --git a/scripts/leaderboard.gd b/scripts/leaderboard.gd new file mode 100644 index 0000000..3e2256a --- /dev/null +++ b/scripts/leaderboard.gd @@ -0,0 +1,170 @@ +extends Node +## Client della classifica remota. +## +## - Web: stessa origine della pagina (window.location.origin) + /api/zombie-fps, cosi' +## il browser non ha bisogno di CORS. +## - Desktop: nessuna classifica, a meno di --api=http://... (serve ai test). Cosi' una +## partita di prova non finisce MAI nella classifica pubblica. +## - Due nodi HTTPRequest separati: Godot non ammette due richieste insieme sullo +## stesso nodo, e lettura e invio possono sovrapporsi (l'invio avviene alla morte). +## - Un errore di rete non deve mai rompere il gioco: si segnala e basta. +signal leaderboard_ready(rows: Array, total: int, me: Dictionary) +signal leaderboard_failed(reason: String) +signal score_submitted(info: Dictionary) +signal submit_failed(reason: String) + +const API_PATH := "/api/zombie-fps" +const TOP_LIMIT := 10 +const RETRY_DELAY := 6.0 +const RETRY_LIMIT := 3 +const TIMEOUT := 12.0 +const DISABLED_REASON := "classifica non attiva in questa build" + +var player_id := "" +var base_url := "" +## Vero quando non c'e' nessun servizio da usare (build desktop senza --api=). +var disabled := false + +var _get_request: HTTPRequest +var _post_request: HTTPRequest +var _pending := {} # invio in corso: nome, punteggio, submission_id +var _retries := 0 +var _retry_timer: Timer +var _fetching := false +var _fetch_again := false + +func _ready() -> void: + name = "Leaderboard" + base_url = _resolve_base() + disabled = base_url == "" + _get_request = HTTPRequest.new() + _get_request.timeout = TIMEOUT + _get_request.body_size_limit = 262144 + add_child(_get_request) + _get_request.request_completed.connect(_on_get_completed) + _post_request = HTTPRequest.new() + _post_request.timeout = TIMEOUT + _post_request.body_size_limit = 65536 + add_child(_post_request) + _post_request.request_completed.connect(_on_post_completed) + _retry_timer = Timer.new() + _retry_timer.one_shot = true + add_child(_retry_timer) + _retry_timer.timeout.connect(_send_pending) + +## Ordine di priorita': --api=..., poi l'origine della pagina sul Web. Senza nessuno +## dei due non si usa NIENTE: una build desktop di prova non deve poter inviare +## punteggi alla classifica pubblica (e' successo: righe "Test" in produzione). +func _resolve_base() -> String: + for argument in OS.get_cmdline_user_args(): + if argument.begins_with("--api="): + return argument.substr(6).trim_suffix("/") + if OS.has_feature("web") and ClassDB.class_exists("JavaScriptBridge"): + var origin := str(JavaScriptBridge.eval("window.location.origin", true)) + if origin.begins_with("http"): + return origin.trim_suffix("/") + API_PATH + push_warning("Classifica disattivata: usa --api=http://host:porta per indicare il servizio.") + return "" + +## Godot non ammette due richieste insieme sullo stesso nodo: una lettura chiesta +## mentre la precedente e' in volo viene rimandata a fine risposta invece di fallire +## (capita davvero: il menu legge all'avvio e il pulsante "aggiorna" puo' essere premuto). +func fetch() -> void: + if _get_request == null: + return + if disabled: + leaderboard_failed.emit(DISABLED_REASON) + return + if _fetching: + _fetch_again = true + return + _fetching = true + var url := "%s/scores?limit=%d" % [base_url, TOP_LIMIT] + if player_id != "": + url += "&player=" + player_id.uri_encode() + var error := _get_request.request(url, PackedStringArray(["Accept: application/json"])) + if error != OK: + _fetching = false + leaderboard_failed.emit("richiesta non avviata") + +func submit(player_name: String, score: int) -> void: + if not _pending.is_empty(): + return # un invio alla volta: il secondo verrebbe scartato dal server + if disabled: + submit_failed.emit(DISABLED_REASON) + return + _pending = { + "name": player_name, + "score": score, + "player_id": player_id, + "submission_id": _new_submission_id(), + } + _retries = 0 + _send_pending() + +func _send_pending() -> void: + if _pending.is_empty() or _post_request == null: + return + var body := JSON.stringify(_pending) + var error := _post_request.request(base_url + "/scores", + PackedStringArray(["Content-Type: application/json", "Accept: application/json"]), + HTTPClient.METHOD_POST, body) + if error != OK: + _fail_pending("invio non avviato") + +## Retry con lo stesso submission_id: il server lo riconosce e non duplica la riga. +func _retry(reason: String) -> void: + if _retries >= RETRY_LIMIT: + _fail_pending(reason) + return + _retries += 1 + _retry_timer.start(RETRY_DELAY * _retries) + +func _fail_pending(reason: String) -> void: + _pending = {} + submit_failed.emit(reason) + +func _on_get_completed(result: int, status: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + _fetching = false + if _fetch_again: # la lettura rimandata parte adesso + _fetch_again = false + fetch() + if result != HTTPRequest.RESULT_SUCCESS: + leaderboard_failed.emit("rete non disponibile") + return + if status != 200: + leaderboard_failed.emit("risposta %d" % status) + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("top"): + leaderboard_failed.emit("risposta non valida") + return + leaderboard_ready.emit(parsed.get("top", []), int(parsed.get("total", 0)), + parsed.get("me", {}) if typeof(parsed.get("me")) == TYPE_DICTIONARY else {}) + +func _on_post_completed(result: int, status: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if _pending.is_empty(): + return + if result != HTTPRequest.RESULT_SUCCESS: + _retry("rete non disponibile") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if status == 429 or status >= 500: + _retry("server occupato") + return + if status != 200 and status != 201: + var detail := "rifiutato" + if typeof(parsed) == TYPE_DICTIONARY and parsed.has("error"): + detail = str(parsed["error"]) + _fail_pending(detail) + return + if typeof(parsed) != TYPE_DICTIONARY: + _fail_pending("risposta non valida") + return + var info: Dictionary = parsed.duplicate() + info["submitted"] = _pending.get("score", 0) + _pending = {} + score_submitted.emit(info) + +func _new_submission_id() -> String: + return preload("res://scripts/player_profile.gd").new_submission_id() diff --git a/scripts/leaderboard.gd.uid b/scripts/leaderboard.gd.uid new file mode 100644 index 0000000..40a6555 --- /dev/null +++ b/scripts/leaderboard.gd.uid @@ -0,0 +1 @@ +uid://dqu86xp3orxdv diff --git a/scripts/main.gd b/scripts/main.gd index eabe29b..675b615 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -2,6 +2,9 @@ extends Node3D const PlayerScript = preload("res://scripts/player.gd") const ZombieScript = preload("res://scripts/zombie.gd") const RoomScript = preload("res://scripts/room.gd") +const LeaderboardScript = preload("res://scripts/leaderboard.gd") +const StartScreenScript = preload("res://scripts/start_screen.gd") +const Profile = preload("res://scripts/player_profile.gd") const MUSIC_PATH := "res://assets/audio/music/zombie_industrial_loop.ogg" const MUSIC_VOLUME_DB := -11.0 const TouchControls = preload("res://scripts/touch_controls.gd") @@ -18,12 +21,15 @@ var alive := 0 var spawning := false var wave_pending := false var smoke := false -var awaiting_web_start := false -var start_label: Label var last_window := -1 +var leaderboard: Node +var start_screen: CanvasLayer +var profile := {} +var score_submitted := false func _ready() -> void: smoke = "--smoke" in OS.get_cmdline_user_args() + profile = Profile.load_profile() room = Node3D.new() room.set_script(RoomScript) add_child(room) @@ -74,44 +80,80 @@ func _ready() -> void: cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER) _setup_music() _setup_touch() + leaderboard = Node.new() + leaderboard.set_script(LeaderboardScript) + leaderboard.player_id = profile["player_id"] + add_child(leaderboard) + leaderboard.leaderboard_ready.connect(_on_leaderboard_ready) + leaderboard.leaderboard_failed.connect(_on_leaderboard_failed) + leaderboard.score_submitted.connect(_on_score_submitted) + leaderboard.submit_failed.connect(_on_submit_failed) + player.died.connect(_on_player_died) + start_screen = CanvasLayer.new() + start_screen.set_script(StartScreenScript) + start_screen.player = player + start_screen.leaderboard = leaderboard + start_screen.profile = profile + add_child(start_screen) + start_screen.started.connect(_on_start_pressed) if OS.has_feature("web"): - awaiting_web_start = true if touch != null: - touch.enabled = false # il primo tocco/click avvia la partita - start_label = Label.new() - var howto := "trascina a sinistra: muovi · destra: guarda · pulsanti: fuoco/granata/ricarica/salto" if player.touch_mode else "WASD: movimento · clic sinistro: spara · destro: granata · R: ricarica" - start_label.text = ("TOCCA PER INIZIARE" if player.touch_mode else "CLICCA PER INIZIARE") + "\n" + howto - start_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - start_label.add_theme_font_size_override("font_size", 24) - start_label.mouse_filter = Control.MOUSE_FILTER_IGNORE - canvas.add_child(start_label) - start_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER) - else: - _start_music() - _next_wave() + touch.enabled = false # i tocchi servono alla schermata iniziale if smoke: get_tree().create_timer(12).timeout.connect(func(): print("SMOKE: wave=", wave, " alive=", alive, " player_hp=", player.hp) get_tree().quit()) -func _unhandled_input(event: InputEvent) -> void: - if not awaiting_web_start: +## Il pulsante GIOCA della schermata iniziale delega qui: sblocca l'audio (policy dei +## browser: serve un gesto dell'utente) e avvia la prima ondata. I test headless +## chiamano la stessa funzione con save_profile=false per non toccare il profilo reale. +func _on_start_pressed(player_name: String) -> void: + start_game(player_name, true) + +func start_game(player_name: String, save_profile := true) -> void: + profile["name"] = player_name + if save_profile: + Profile.save_profile(profile) + if not player.touch_mode: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + if touch != null: + touch.enabled = true + if start_screen != null: + start_screen.queue_free() + start_screen = null + _start_music() + _next_wave() + +## Alla morte si invia UNA volta il punteggio finale con nome e id del giocatore. +func _on_player_died() -> void: + if score_submitted: return - var started := false - if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: - started = true - elif event is InputEventScreenTouch and event.pressed: - started = true # primo tocco su mobile - if started: - awaiting_web_start = false - start_label.queue_free() - if not player.touch_mode: - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED - if touch != null: - touch.enabled = true - # Il browser concede l'audio solo dopo un gesto dell'utente: qui il click/tocco. - _start_music() - _next_wave() + score_submitted = true + if int(score) > int(profile.get("best", 0)): + profile["best"] = score + Profile.save_profile(profile) + player.set_death_note("invio del punteggio in corso...") + leaderboard.submit(profile["name"], score) + +func _on_score_submitted(info: Dictionary) -> void: + var total := int(info.get("total", 0)) + player.set_death_note("PUNTEGGIO %d — %d° su %d partite" % [score, int(info.get("rank", 0)), total]) + leaderboard.fetch() + +func _on_submit_failed(reason: String) -> void: + player.set_death_note("punteggio non inviato (%s)" % reason) + +func _on_leaderboard_ready(rows: Array, total: int, me: Dictionary) -> void: + if start_screen != null: + start_screen.show_leaderboard(rows, total, me) + +func _on_leaderboard_failed(reason: String) -> void: + if start_screen != null: + start_screen.show_leaderboard_error(reason) + +## Nota: qui non c'e' piu' nessuna rete di sicurezza sull'input. La partita parte +## solo dal pulsante GIOCA della schermata iniziale (segnale `started`): un click o un +## tocco sullo sfondo del menu non deve avviare nulla. ## Controlli on-screen: attivi su browser mobile o touchscreen, forzabili con ## --touch / --no-touch sulla riga di comando e con ?touch=1 / ?touch=0 sul Web. diff --git a/scripts/player.gd b/scripts/player.gd index 2541a24..79881fc 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -1,4 +1,5 @@ extends CharacterBody3D +signal died var camera: Camera3D var damage_pivot: Node3D var damage_feedback: CanvasLayer @@ -81,6 +82,14 @@ func _ready() -> void: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED var restart_button: Button +var death_note: Label + +## Riga sotto il messaggio di morte: punteggio inviato, posizione in classifica o +## motivo del fallimento. Richiamata da main.gd a conclusione dell'invio. +func set_death_note(text: String) -> void: + if is_instance_valid(death_note): + death_note.text = text + death_note.modulate.a = 1.0 func _input(event: InputEvent) -> void: # Touch nativo: funziona anche con emulate_mouse_from_touch disattivato. @@ -248,6 +257,20 @@ func _die() -> void: layer.add_child(message) message.set_anchors_and_offsets_preset(Control.PRESET_CENTER) tween.tween_property(message, "modulate:a", 1.0, 0.5).set_delay(2.2) + death_note = Label.new() + death_note.text = "" + death_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + death_note.add_theme_font_size_override("font_size", 22) + death_note.add_theme_color_override("font_color", Color(1.0, 0.66, 0.16)) + death_note.modulate.a = 0 + layer.add_child(death_note) + death_note.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + death_note.offset_top = 60 + death_note.offset_bottom = 100 + death_note.offset_left = -420 + death_note.offset_right = 420 + tween.tween_property(death_note, "modulate:a", 1.0, 0.5).set_delay(2.6) + died.emit() if touch_mode: var restart := Button.new() restart_button = restart diff --git a/scripts/player_profile.gd b/scripts/player_profile.gd new file mode 100644 index 0000000..654ac47 --- /dev/null +++ b/scripts/player_profile.gd @@ -0,0 +1,207 @@ +extends RefCounted +## Profilo locale del giocatore: nome scelto, id persistente e ultimo punteggio. +## Su desktop vive in user://, sul Web si scrive ANCHE in window.localStorage, perche' +## e' la memoria del browser che sopravvive davvero alla ricarica della pagina (la +## IndexedDB di Godot puo' non essere sincronizzata). Chiavi: zombie_fps_*. +## Il nome, una volta scelto, resta bloccato: non viene piu' richiesto. +const PATH := "user://zombie_fps_profile.cfg" +const DEFAULT_NAME := "Giocatore" +const NAME_MAX := 16 +const KEY_NAME := "zombie_fps_name" +const KEY_PLAYER_ID := "zombie_fps_player_id" +const KEY_BEST := "zombie_fps_best" +const KEY_LOCKED := "zombie_fps_locked" + +## Legge il profilo (o i valori predefiniti) e lo riscrive, cosi' il primo avvio +## lascia sempre un id stabile e le chiavi del browser allineate. +static func load_profile(path := PATH) -> Dictionary: + var data := {"name": DEFAULT_NAME, "player_id": "", "best": 0, "locked": false} + var config := ConfigFile.new() + if config.load(path) == OK: + var raw_name := text_of(config.get_value("player", "name", DEFAULT_NAME)) + # Una versione precedente poteva salvare la stringa "null": in quel caso il + # nome va considerato non scelto e il blocco rimosso (riparazione automatica). + data["name"] = sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME + data["player_id"] = str(config.get_value("player", "player_id", "")) + data["best"] = int(config.get_value("player", "best", 0)) + data["locked"] = bool(config.get_value("player", "locked", false)) and is_stored_name_usable(raw_name) + if path == PATH: + _web_merge(data) + if data["player_id"] == "": + data["player_id"] = new_player_id() + # Vale anche per i profili scritti prima di questa regola: un nome diverso da + # quello predefinito significa che il giocatore l'ha scelto di proposito. + data["locked"] = is_name_locked(data) + save_profile(data, path) + return data + +static func save_profile(data: Dictionary, path := PATH) -> void: + var raw_name := text_of(data.get("name")) + var name := sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME + var locked := is_name_locked(data) + data["name"] = name + data["locked"] = locked + var config := ConfigFile.new() + config.set_value("player", "name", name) + config.set_value("player", "player_id", str(data.get("player_id", ""))) + config.set_value("player", "best", int(data.get("best", 0))) + config.set_value("player", "locked", locked) + config.save(path) + if path == PATH: + _web_set(KEY_NAME, name) + _web_set(KEY_PLAYER_ID, str(data.get("player_id", ""))) + _web_set(KEY_BEST, str(int(data.get("best", 0)))) + _web_set(KEY_LOCKED, "1" if locked else "0") + +## Vero se il testo e' un nome memorizzato utilizzabile. Le versioni precedenti +## potevano salvare la stringa "null" (`str(null)` in GDScript): va scartata. +static func is_stored_name_usable(value: Variant) -> bool: + var text := text_of(value).strip_edges() + return text != "" and text != "null" and text != "undefined" + +## Il nome e' bloccato quando il giocatore l'ha scelto: non si richiede piu'. +static func is_name_locked(data: Dictionary) -> bool: + if bool(data.get("locked", false)): + return true + return sanitize_name(str(data.get("name", DEFAULT_NAME))) != DEFAULT_NAME + +## Id persistente del giocatore: 32 caratteri esadecimali casuali. +static func new_player_id() -> String: + var bytes := random_bytes(16) + var text := "" + for byte in bytes: + text += "%02x" % byte + return text + +## Uuid v4 per identificare UNA partita: il server lo usa per non duplicare i retry. +static func new_submission_id() -> String: + var bytes := random_bytes(16) + bytes[6] = (bytes[6] & 0x0f) | 0x40 # versione 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80 # variante + var text := "" + for byte in bytes: + text += "%02x" % byte + return "%s-%s-%s-%s-%s" % [text.substr(0, 8), text.substr(8, 4), text.substr(12, 4), + text.substr(16, 4), text.substr(20, 12)] + +## Byte casuali da Crypto, con ripiego sul generatore della scena se non disponibile. +static func random_bytes(count: int) -> PackedByteArray: + if ClassDB.class_exists("Crypto"): + var crypto := Crypto.new() + var bytes := crypto.generate_random_bytes(count) + if bytes.size() == count: + return bytes + var rng := RandomNumberGenerator.new() + rng.randomize() + var fallback := PackedByteArray() + for i in count: + fallback.append(rng.randi_range(0, 255)) + return fallback + +## Id del giocatore secondo le stesse regole del server (8-64 caratteri sicuri). +static func is_valid_player_id(text: String) -> bool: + if text.length() < 8 or text.length() > 64: + return false + for character in text: + var allowed := character.is_valid_int() or character == "-" or character == "_" + allowed = allowed or (character.to_lower() != character.to_upper()) + if not allowed: + return false + return true + +## Pulizia del nome secondo le stesse regole del server: 1-16 caratteri utili, +## niente apici/emoji (il server risponderebbe 400 e il punteggio andrebbe perso). +static func sanitize_name(raw: String) -> String: + var cleaned := "" + for character in raw.strip_edges(): + if cleaned.length() >= NAME_MAX: + break + if character == "\n" or character == "\t" or character == "\r": + character = " " + var allowed := character == " " or character == "-" or character == "." + allowed = allowed or character.is_valid_int() + allowed = allowed or (character.to_lower() != character.to_upper()) + if allowed: + cleaned += character + cleaned = cleaned.strip_edges() + while cleaned.contains(" "): + cleaned = cleaned.replace(" ", " ") + if cleaned.strip_edges() == "": + cleaned = DEFAULT_NAME + return cleaned + +## Testo pronto per un letterale JavaScript fra apici singoli. +static func js_escape(text: String) -> String: + var out := "" + for character in text: + if character == "\\": + out += "\\\\" + elif character == "'": + out += "\\'" + elif character == "\n" or character == "\r" or character == "\t": + out += " " + else: + out += character + return out + +## --- memoria del browser (solo sul Web) ------------------------------------- + +static func _web_eval(code: String) -> String: + if not OS.has_feature("web"): + return "" + if not ClassDB.class_exists("JavaScriptBridge"): + return "" + return str(JavaScriptBridge.eval(code, true)) + +static func _web_merge(data: Dictionary) -> void: + var raw := _web_eval( + "(function(){try{return JSON.stringify({n:window.localStorage.getItem('%s')||''," % KEY_NAME + + "i:window.localStorage.getItem('%s')||''," % KEY_PLAYER_ID + + "b:window.localStorage.getItem('%s')||''," % KEY_BEST + + "l:window.localStorage.getItem('%s')||''});}catch(e){return '';}})()" % KEY_LOCKED) + if raw == "" or raw == "": + return + apply_web_json(data, raw) + +## Applica i valori letti dal browser al profilo. E' una funzione PURA (riceve il +## JSON come testo) cosi' si puo' verificare senza browser: attenzione, in GDScript +## `str(null)` vale "null", quindi i valori assenti vanno scartati, non convertiti. +static func apply_web_json(data: Dictionary, raw: String) -> Dictionary: + var parsed: Variant = JSON.parse_string(raw) + if not (parsed is Dictionary): + return data + var stored: Dictionary = parsed + var raw_name := text_of(stored.get("n")) + if raw_name != "": + if is_stored_name_usable(raw_name): + data["name"] = sanitize_name(raw_name) + else: + data["name"] = DEFAULT_NAME + data["locked"] = false + var player_id := text_of(stored.get("i")) + if is_valid_player_id(player_id): + data["player_id"] = player_id + var best := text_of(stored.get("b")) + if best.is_valid_int(): + data["best"] = int(best) + if text_of(stored.get("l")) == "1" and is_stored_name_usable(raw_name): + data["locked"] = true + return data + +## Testo di un valore che puo' arrivare dal browser o da un file: null e numeri +## diventano stringhe sensate (in GDScript `str(null)` produrrebbe "null"). +static func text_of(value: Variant) -> String: + if value == null: + return "" + if value is String: + return str(value).strip_edges() + return str(value) + +static func _web_set(key: String, value: String) -> void: + _web_eval("(function(){try{window.localStorage.setItem('%s','%s');return '1';}catch(e){return '0';}})()" + % [js_escape(key), js_escape(value)]) + +## Cancella le chiavi del browser: serve ai test, non al gioco. +static func web_clear() -> void: + for key in [KEY_NAME, KEY_PLAYER_ID, KEY_BEST, KEY_LOCKED]: + _web_eval("(function(){try{window.localStorage.removeItem('%s');return '1';}catch(e){return '0';}})()" % key) diff --git a/scripts/player_profile.gd.uid b/scripts/player_profile.gd.uid new file mode 100644 index 0000000..43d3e1b --- /dev/null +++ b/scripts/player_profile.gd.uid @@ -0,0 +1 @@ +uid://2o00kx51f760 diff --git a/scripts/start_screen.gd b/scripts/start_screen.gd new file mode 100644 index 0000000..f9a6259 --- /dev/null +++ b/scripts/start_screen.gd @@ -0,0 +1,198 @@ +extends CanvasLayer +## Schermata iniziale: nome del giocatore, pulsante di avvio e classifica globale +## (tutte le partite). Il pulsante di avvio e' anche il gesto che sblocca l'audio +## nel browser, quindi la partita parte solo da qui. +signal started(player_name: String) + +const Profile = preload("res://scripts/player_profile.gd") +const AMBER := Color(1.0, 0.66, 0.16) +const DIM := Color(0.85, 0.78, 0.6) +const ROWS_DESKTOP := 10 +const ROWS_TOUCH := 5 + +var player: Node +var leaderboard: Node +var profile := {} +var name_field: LineEdit +var rows_box: VBoxContainer +var status_label: Label +var me_label: Label +var rows_shown := ROWS_DESKTOP +## Vero quando il nome e' gia' stato scelto: niente campo di testo, solo il saluto. +var name_locked := false + +func _ready() -> void: + layer = 4 + name_locked = Profile.is_name_locked(profile) + rows_shown = ROWS_TOUCH if (player != null and player.touch_mode) else ROWS_DESKTOP + _build_ui() + if leaderboard != null: + leaderboard.fetch() + +## Nome con cui si giochera': quello memorizzato se e' bloccato, altrimenti il campo. +func _stored_name() -> String: + return Profile.sanitize_name(str(profile.get("name", Profile.DEFAULT_NAME))) + +func _build_ui() -> void: + var touch_mode: bool = player != null and player.touch_mode + var viewport := get_viewport().get_visible_rect().size + var width := minf(620.0, viewport.x * 0.92) + var backdrop := ColorRect.new() + backdrop.color = Color(0.01, 0.01, 0.02, 0.92) + backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(backdrop) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(center) + var column := VBoxContainer.new() + column.custom_minimum_size = Vector2(width, 0) + column.add_theme_constant_override("separation", 10 if touch_mode else 14) + center.add_child(column) + + var title := _label("ZOMBIE ROOM FPS", 34 if touch_mode else 44, AMBER, HORIZONTAL_ALIGNMENT_CENTER) + column.add_child(title) + column.add_child(_label("MatGames — classifica globale", 15, DIM, HORIZONTAL_ALIGNMENT_CENTER)) + + if name_locked: + # Nome gia' scelto: non si richiede piu' (e nessun campo da toccare per sbaglio). + column.add_child(_label("giocatore: %s" % _stored_name(), 22, AMBER, HORIZONTAL_ALIGNMENT_CENTER)) + else: + column.add_child(_build_name_row()) + + var play := Button.new() + play.text = "GIOCA" + play.add_theme_font_size_override("font_size", 30) + play.custom_minimum_size = Vector2(0, 56) + play.pressed.connect(_start) + column.add_child(play) + + var panel := PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = Color(0.03, 0.025, 0.01, 0.9) + style.border_color = Color(1.0, 0.66, 0.16, 0.4) + style.set_border_width_all(1) + style.set_corner_radius_all(4) + style.set_content_margin_all(10) + panel.add_theme_stylebox_override("panel", style) + column.add_child(panel) + var board_column := VBoxContainer.new() + board_column.add_theme_constant_override("separation", 4) + panel.add_child(board_column) + var header := HBoxContainer.new() + board_column.add_child(header) + header.add_child(_label("CLASSIFICA", 17, AMBER, HORIZONTAL_ALIGNMENT_LEFT)) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(spacer) + var refresh := Button.new() + refresh.text = "aggiorna" + refresh.add_theme_font_size_override("font_size", 15) + refresh.pressed.connect(func(): _set_status("aggiornamento...") ; leaderboard.fetch()) + header.add_child(refresh) + rows_box = VBoxContainer.new() + rows_box.add_theme_constant_override("separation", 2) + board_column.add_child(rows_box) + me_label = _label("", 15, DIM, HORIZONTAL_ALIGNMENT_LEFT) + board_column.add_child(me_label) + status_label = _label("caricamento classifica...", 14, DIM, HORIZONTAL_ALIGNMENT_LEFT) + board_column.add_child(status_label) + _show_rows([]) + +## Riga del nome, mostrata solo finche' il nome non e' stato scelto. +func _build_name_row() -> HBoxContainer: + var name_row := HBoxContainer.new() + name_row.add_theme_constant_override("separation", 8) + name_field = LineEdit.new() + name_field.text = str(profile.get("name", Profile.DEFAULT_NAME)) + name_field.max_length = Profile.NAME_MAX + name_field.placeholder_text = "il tuo nome" + name_field.virtual_keyboard_enabled = true # necessario su mobile Web + name_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_field.add_theme_font_size_override("font_size", 22) + # Invio / tasto "Vai" della tastiera del telefono: conferma il nome, NON avvia + # la partita (prima lo faceva e su mobile partiva la partita per sbaglio). + name_field.text_submitted.connect(func(text): name_field.text = Profile.sanitize_name(text)) + name_row.add_child(name_field) + var edit := Button.new() + edit.text = "nome…" + edit.tooltip_text = "tastiera del telefono / finestra del browser" + edit.add_theme_font_size_override("font_size", 18) + edit.pressed.connect(_ask_name) + name_row.add_child(edit) + return name_row + +func _label(text: String, size: int, color: Color, align: int) -> Label: + var label := Label.new() + label.text = text + label.horizontal_alignment = align + label.add_theme_color_override("font_color", color) + label.add_theme_color_override("font_shadow_color", Color.BLACK) + label.add_theme_font_size_override("font_size", size) + return label + +func _set_status(text: String) -> void: + if status_label != null: + status_label.text = text + +## Righe della classifica: tre colonne, per restare leggibile senza font monospaziato. +func _show_rows(rows: Array) -> void: + for child in rows_box.get_children(): + child.queue_free() + if rows.is_empty(): + rows_box.add_child(_label("nessun punteggio registrato: gioca la prima partita", 15, DIM, HORIZONTAL_ALIGNMENT_LEFT)) + return + var mine := str(profile.get("player_id", "")) + for i in mini(rows_shown, rows.size()): + var row: Dictionary = rows[i] + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 6) + var rank := _label("%d." % int(row.get("rank", i + 1)), 17, AMBER, HORIZONTAL_ALIGNMENT_RIGHT) + rank.custom_minimum_size = Vector2(38, 0) + line.add_child(rank) + var is_mine := str(row.get("player_id", "")) == mine and mine != "" + var who := _label(str(row.get("name", "?")), 17, AMBER if is_mine else Color.WHITE, + HORIZONTAL_ALIGNMENT_LEFT) + who.size_flags_horizontal = Control.SIZE_EXPAND_FILL + who.clip_text = true + line.add_child(who) + var score_label := _label(str(int(row.get("score", 0))), 17, AMBER, HORIZONTAL_ALIGNMENT_RIGHT) + score_label.custom_minimum_size = Vector2(80, 0) + line.add_child(score_label) + rows_box.add_child(line) + +func show_leaderboard(rows: Array, total: int, me: Dictionary) -> void: + if rows_box == null: + return + _show_rows(rows) + _set_status("%d partite registrate" % total if total > 0 else "nessuna partita registrata") + if me.is_empty(): + me_label.text = "" + else: + me_label.text = "il tuo miglior punteggio: %d (%d° posto, %d partite)" % [ + int(me.get("best", 0)), int(me.get("rank", 0)), int(me.get("games", 0))] + +func show_leaderboard_error(reason: String) -> void: + _set_status("classifica non disponibile (%s)" % reason) + +## Su Web la tastiera virtuale di Godot e' sperimentale: in aggiunta al campo di testo +## si offre il prompt nativo del browser, che ha sempre la tastiera del telefono. +func _ask_name() -> void: + if name_locked or name_field == null: + return + var current := Profile.js_escape(name_field.text) + if OS.has_feature("web") and ClassDB.class_exists("JavaScriptBridge"): + var answer := str(JavaScriptBridge.eval( + "window.prompt('Il tuo nome (max 16 caratteri):', '%s') || ''" % current, true)) + if answer.strip_edges() != "": + name_field.text = Profile.sanitize_name(answer) + return + name_field.grab_focus() + +## La partita parte SOLO da qui, cioe' dal pulsante GIOCA: un click o un tocco +## altrove (sfondo, campo del nome, tasto Invio della tastiera del telefono) non deve +## avviare nulla. +func _start() -> void: + if name_locked or name_field == null: + started.emit(_stored_name()) + return + started.emit(Profile.sanitize_name(name_field.text)) diff --git a/scripts/start_screen.gd.uid b/scripts/start_screen.gd.uid new file mode 100644 index 0000000..12b2a83 --- /dev/null +++ b/scripts/start_screen.gd.uid @@ -0,0 +1 @@ +uid://djsrmu6t131d diff --git a/services/scores-api/.dockerignore b/services/scores-api/.dockerignore new file mode 100644 index 0000000..900b1b0 --- /dev/null +++ b/services/scores-api/.dockerignore @@ -0,0 +1,3 @@ +data/ +*.pyc +__pycache__/ diff --git a/services/scores-api/Dockerfile b/services/scores-api/Dockerfile new file mode 100644 index 0000000..eff9a24 --- /dev/null +++ b/services/scores-api/Dockerfile @@ -0,0 +1,18 @@ +# Immagine minima: nessuna dipendenza pip, nessuna rete in fase di build. +FROM python:3.12-alpine3.20 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DATABASE_PATH=/data/scores.db \ + PORT=8791 + +WORKDIR /app +COPY app.py /app/app.py +RUN adduser -D -u 10001 scores && mkdir -p /data && chown scores /data +USER scores + +EXPOSE 8791 +HEALTHCHECK --interval=15s --timeout=3s --retries=3 --start-period=5s \ + CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8791/health',timeout=2)" + +CMD ["python", "/app/app.py"] diff --git a/services/scores-api/app.py b/services/scores-api/app.py new file mode 100644 index 0000000..700c5e3 --- /dev/null +++ b/services/scores-api/app.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Zombie Room FPS — leaderboard API. + +Solo libreria standard: nessuna dipendenza pip, immagine minima e codice +verificabile. Dietro nginx: il percorso pubblico e' /api/zombie-fps/ e nginx +rimuove quel prefisso prima di inoltrare, quindi qui i percorsi sono /health, +/scores, ... + +Endpoint + GET /health stato del servizio + GET /scores?limit=10&player= classifica (tutte le partite) + il tuo riassunto + POST /scores {"player_id","name","score","submission_id"} + +Il punteggio arriva dal client ed e' quindi falsificabile: non c'e' anti-cheat. +Le difese sono tetto di plausibilita', rate limit e idempotenza dei retry. +""" +import json +import os +import re +import sqlite3 +import threading +import time +import uuid +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +VERSION = "1.0.0" +SERVICE = "zombie-fps-scores" + +DB_PATH = os.environ.get("DATABASE_PATH", "/data/scores.db") +PORT = int(os.environ.get("PORT", "8791")) +BIND = os.environ.get("BIND", "0.0.0.0") + +MAX_BODY_BYTES = int(os.environ.get("MAX_BODY_BYTES", "2048")) +NAME_RE = re.compile(r"^[\w \-.]{1,16}$", re.UNICODE) # lettere (anche accentate), cifre, spazio, _ - . +ID_RE = re.compile(r"^[A-Za-z0-9_\-]{8,64}$") +# Il gioco assegna 100/150 per zombie: il punteggio e' sempre multiplo di 50. +SCORE_MIN, SCORE_MAX, SCORE_STEP = 0, 200000, 50 +NAME_MAX = 16 +TOP_DEFAULT, TOP_MAX = 10, 50 +# Limiti ant-abuso, configurabili per poterli testare su un'istanza dedicata. +POST_PER_HOUR = int(os.environ.get("POST_PER_HOUR", "20")) +POST_MIN_INTERVAL = float(os.environ.get("POST_MIN_INTERVAL", "5.0")) +GET_PER_MINUTE = int(os.environ.get("GET_PER_MINUTE", "120")) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS scores ( + id TEXT PRIMARY KEY, + player_id TEXT NOT NULL, + submission_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + score INTEGER NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_scores_rank ON scores(score DESC, created_at ASC); +CREATE INDEX IF NOT EXISTS idx_scores_player ON scores(player_id, score DESC); +""" + +_lock = threading.Lock() +_post_by_ip: dict = {} # ip -> [timestamp, ...] +_last_by_player: dict = {} # player_id -> timestamp +_get_by_ip: dict = {} # ip -> [timestamp, ...] + + +class ApiError(Exception): + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + self.message = message + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def connect() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=5.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA journal_mode = WAL") # regge lettori e un scrittore insieme + conn.execute("PRAGMA synchronous = FULL") # un invio confermato sopravvive al riavvio + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db() -> None: + os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True) + conn = connect() + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() + + +def rank_of(conn: sqlite3.Connection, score: int, created_at: str) -> int: + row = conn.execute( + "SELECT COUNT(*) AS n FROM scores WHERE score > ? OR (score = ? AND created_at < ?)", + (score, score, created_at)).fetchone() + return int(row["n"]) + 1 + + +def clamp_seconds(window: float, stamps: list) -> list: + cutoff = time.monotonic() - window + return [s for s in stamps if s >= cutoff] + + +def check_rate(store: dict, key: str, window: float, limit: int) -> None: + """Solo verifica: non modifica lo stato, cosi' un invio respinto per un altro + motivo non consuma il budget dell'IP.""" + stamps = clamp_seconds(window, store.get(key, [])) + store[key] = stamps + if len(stamps) >= limit: + raise ApiError(429, "troppi invii, riprova piu' tardi") + + +def record_hit(store: dict, key: str) -> None: + store.setdefault(key, []).append(time.monotonic()) + + +def validate_submission(payload: dict) -> dict: + if not isinstance(payload, dict): + raise ApiError(400, "corpo JSON non valido") + name = payload.get("name") + player_id = payload.get("player_id") + submission_id = payload.get("submission_id") + score = payload.get("score") + if not isinstance(name, str): + raise ApiError(400, "nome mancante") + name = " ".join(name.split()) # niente newline, tab, spazi doppi + if not NAME_RE.match(name): + raise ApiError(400, "nome non valido: 1-16 caratteri, lettere cifre spazio . _ -") + if not isinstance(player_id, str) or not ID_RE.match(player_id): + raise ApiError(400, "player_id non valido") + if not isinstance(submission_id, str): + raise ApiError(400, "submission_id mancante") + try: + uuid.UUID(submission_id) + except (ValueError, AttributeError): + raise ApiError(400, "submission_id deve essere un uuid") + if isinstance(score, bool) or not isinstance(score, int): + raise ApiError(400, "punteggio deve essere un intero") + if score < SCORE_MIN or score > SCORE_MAX or score % SCORE_STEP: + raise ApiError(400, "punteggio fuori dai limiti plausibili") + return {"name": name, "player_id": player_id, "submission_id": submission_id.lower(), + "score": score} + + +def submit(payload: dict, ip: str) -> tuple: + data = validate_submission(payload) + conn = connect() + try: + # L'idempotenza si verifica PRIMA dei rate limit: il retry dello stesso invio non e' + # carico nuovo e non deve essere bloccato dal limite per giocatore o per IP. + existing = conn.execute("SELECT * FROM scores WHERE submission_id = ?", + (data["submission_id"],)).fetchone() + if existing is not None: + return 200, {"ok": True, "duplicate": True, "id": existing["id"], + "rank": rank_of(conn, existing["score"], existing["created_at"]), + "total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]} + with _lock: + check_rate(_post_by_ip, ip, 3600.0, POST_PER_HOUR) + last = _last_by_player.get(data["player_id"]) + if last is not None and time.monotonic() - last < POST_MIN_INTERVAL: + raise ApiError(429, "hai appena inviato un punteggio") + # Solo qui l'invio e' accettato: ora si consuma il budget. + record_hit(_post_by_ip, ip) + _last_by_player[data["player_id"]] = time.monotonic() + row_id = str(uuid.uuid4()) # id univoco casuale della riga, generato dal server + created = now_iso() + try: + conn.execute( + "INSERT INTO scores (id, player_id, submission_id, name, score, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (row_id, data["player_id"], data["submission_id"], data["name"], data["score"], created)) + conn.commit() + except sqlite3.IntegrityError: + # Due invii identici in parallelo: il secondo e' un duplicato, non un errore. + existing = conn.execute("SELECT * FROM scores WHERE submission_id = ?", + (data["submission_id"],)).fetchone() + if existing is None: + raise + return 200, {"ok": True, "duplicate": True, "id": existing["id"], + "rank": rank_of(conn, existing["score"], existing["created_at"]), + "total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]} + return 201, {"ok": True, "duplicate": False, "id": row_id, + "rank": rank_of(conn, data["score"], created), + "total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]} + finally: + conn.close() + + +def leaderboard(limit: int, player_id: str | None) -> dict: + conn = connect() + try: + total = int(conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]) + rows = conn.execute( + "SELECT id, player_id, name, score, created_at FROM scores " + "ORDER BY score DESC, created_at ASC LIMIT ?", (limit,)).fetchall() + top = [{"rank": i + 1, "id": r["id"], "player_id": r["player_id"], "name": r["name"], + "score": int(r["score"]), "created_at": r["created_at"]} + for i, r in enumerate(rows)] + me = None + if player_id: + best = conn.execute( + "SELECT * FROM scores WHERE player_id = ? ORDER BY score DESC, created_at ASC LIMIT 1", + (player_id,)).fetchone() + if best is not None: + me = {"best": int(best["score"]), "name": best["name"], + "created_at": best["created_at"], + "rank": rank_of(conn, best["score"], best["created_at"]), + "games": int(conn.execute( + "SELECT COUNT(*) AS n FROM scores WHERE player_id = ?", + (player_id,)).fetchone()["n"])} + return {"top": top, "total": total, "limit": limit, "me": me} + finally: + conn.close() + + +class Handler(BaseHTTPRequestHandler): + server_version = "zombie-fps-scores/" + VERSION + protocol_version = "HTTP/1.1" + + def log_message(self, fmt: str, *args) -> None: + print("[%s] %s %s" % (now_iso(), self.address_string(), fmt % args), flush=True) + + def _send(self, status: int, payload: dict, extra=None) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + for key, value in (extra or {}).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def _fail(self, error: ApiError) -> None: + extra = {"Retry-After": "60"} if error.status == 429 else None + self._send(error.status, {"error": error.message}, extra) + + def _client_ip(self) -> str: + # nginx e' l'unico ingresso: usa l'IP reale inoltrato, con ripiego sul peer. + forwarded = self.headers.get("X-Real-IP") or self.headers.get("X-Forwarded-For", "") + return forwarded.split(",")[0].strip() or self.client_address[0] + + def _read_json(self) -> dict: + length = self.headers.get("Content-Length") + try: + size = int(length) if length is not None else 0 + except ValueError: + raise ApiError(400, "Content-Length non valido") + if size > MAX_BODY_BYTES: + raise ApiError(413, "corpo troppo grande") + raw = self.rfile.read(size) if size else b"" + try: + return json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + raise ApiError(400, "JSON non valido") + + def do_GET(self) -> None: + parsed = urlparse(self.path) + path, query = parsed.path.rstrip("/") or "/", parse_qs(parsed.query) + try: + if path == "/health": + conn = connect() + try: + rows = int(conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]) + finally: + conn.close() + self._send(200, {"ok": True, "service": SERVICE, "version": VERSION, "rows": rows}) + return + if path == "/scores": + with _lock: + check_rate(_get_by_ip, self._client_ip(), 60.0, GET_PER_MINUTE) + try: + limit = int(query.get("limit", [str(TOP_DEFAULT)])[0]) + except ValueError: + raise ApiError(400, "limit non valido") + limit = max(1, min(TOP_MAX, limit)) + player = query.get("player", [None])[0] + if player and not ID_RE.match(player): + raise ApiError(400, "player non valido") + payload = leaderboard(limit, player) + with _lock: + record_hit(_get_by_ip, self._client_ip()) # solo una lettura servita + self._send(200, payload) + return + raise ApiError(404, "percorso non trovato") + except ApiError as error: + self._fail(error) + except Exception as error: # qualunque altro errore: 500 JSON + print("ERRORE /%s: %r" % (path, error), flush=True) + self._send(500, {"error": "errore interno"}) + + def do_POST(self) -> None: + path = urlparse(self.path).path.rstrip("/") or "/" + try: + if path != "/scores": + raise ApiError(404, "percorso non trovato") + if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json": + raise ApiError(415, "Content-Type deve essere application/json") + status, payload = submit(self._read_json(), self._client_ip()) + self._send(status, payload) + except ApiError as error: + self._fail(error) + except Exception as error: + print("ERRORE POST %s: %r" % (path, error), flush=True) + self._send(500, {"error": "errore interno"}) + + def do_HEAD(self) -> None: + self.do_GET() + + def do_OPTIONS(self) -> None: # solo per lo sviluppo locale + origin = self.headers.get("Origin", "") + allowed = os.environ.get("DEV_CORS_ORIGINS", "") + if origin and origin in [o.strip() for o in allowed.split(",") if o.strip()]: + self._send(204, {}, {"Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "600"}) + else: + self._send(204, {}) + + +def main() -> None: + init_db() + server = ThreadingHTTPServer((BIND, PORT), Handler) + server.daemon_threads = True + print("%s %s in ascolto su %s:%d (db %s)" % (SERVICE, VERSION, BIND, PORT, DB_PATH), flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/services/scores-api/docker-compose.yml b/services/scores-api/docker-compose.yml new file mode 100644 index 0000000..89f6b20 --- /dev/null +++ b/services/scores-api/docker-compose.yml @@ -0,0 +1,35 @@ +# Zombie Room FPS — API della classifica. +# In ascolto SOLO su loopback: l'unico ingresso pubblico e' nginx (reverse proxy +# su /api/zombie-fps/). Per avviare: docker compose up -d --build +services: + scores: + build: . + image: zombie-fps-scores:1.0.0 + container_name: zombie-fps-scores + restart: unless-stopped + init: true + # Il processo gira con l'uid dell'utente host che possiede ./data: cosi' il + # database e i file WAL restano leggibili e backuppabili senza sudo. + user: "1000:1000" + environment: + DATABASE_PATH: /data/scores.db + PORT: "8791" + MAX_BODY_BYTES: "2048" + volumes: + # Si monta la DIRECTORY: con WAL accanto al database ci sono anche -wal e -shm. + - ./data:/data + ports: + - "127.0.0.1:8791:8791" + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + stop_grace_period: 20s + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" diff --git a/tools/nginx-zombie-fps-api.conf b/tools/nginx-zombie-fps-api.conf new file mode 100644 index 0000000..691ebf0 --- /dev/null +++ b/tools/nginx-zombie-fps-api.conf @@ -0,0 +1,24 @@ +# API della classifica di Zombie Room FPS. +# Il gioco sta su /pub/zombie-fps/, l'API su /api/zombie-fps/ dello STESSO host: +# stessa origine, quindi il browser non ha bisogno di CORS. Il servizio ascolta +# solo su 127.0.0.1:8791 e nginx rimuove il prefisso prima di inoltrare. +location ^~ /api/zombie-fps/ { + proxy_pass http://127.0.0.1:8791/; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 3s; + proxy_read_timeout 15s; + proxy_send_timeout 15s; + + # I punteggi sono piccoli: un corpo grande non ha motivo di esistere. + client_max_body_size 2k; + + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; +} diff --git a/tools/test_death_effects.gd b/tools/test_death_effects.gd index a742000..48e995c 100644 --- a/tools/test_death_effects.gd +++ b/tools/test_death_effects.gd @@ -5,6 +5,7 @@ func _initialize() -> void: func run_test() -> void: world = load("res://scenes/main.tscn").instantiate() root.add_child(world) + world.start_game("Test", false) # come premere GIOCA, senza toccare il profilo salvato Input.mouse_mode = Input.MOUSE_MODE_VISIBLE await create_timer(2.4).timeout var zombie: Node3D diff --git a/tools/test_leaderboard.gd b/tools/test_leaderboard.gd new file mode 100644 index 0000000..0fb58fc --- /dev/null +++ b/tools/test_leaderboard.gd @@ -0,0 +1,150 @@ +extends SceneTree +## Verifica il client della classifica contro un'istanza locale dell'API. +## Avviare con: tools/test_leaderboard.sh (che avvia il servizio e passa --api=...) +## +## NOTA GDScript: le lambda catturano le variabili LOCALI per valore, quindi gli +## assegnamenti fatti dentro una lambda non si vedono fuori. Qui i valori modificati +## dai segnali sono variabili di CLASSE, non locali. +const Profile = preload("res://scripts/player_profile.gd") +const Leaderboard = preload("res://scripts/leaderboard.gd") + +var world: Node3D +var api := "" +var failures := 0 +var rows: Array = [] +var received := false +var info: Dictionary = {} +var submitted := false +var info2: Dictionary = {} +var submitted2 := false +var submit_events := 0 +var fetch_failures := 0 +var offline_reason := "" + +func _initialize() -> void: + call_deferred("run_test") + +func check(label: String, condition: bool, detail: String = "") -> void: + if condition: + print("PASS: " + label) + else: + failures += 1 + print("FAIL: %s %s" % [label, detail]) + +func run_test() -> void: + for argument in OS.get_cmdline_user_args(): + if argument.begins_with("--api="): + api = argument.substr(6) + world = load("res://scenes/main.tscn").instantiate() + root.add_child(world) + var client = world.leaderboard + + # --- indirizzo e struttura del client --- + check("il client risolve l'url passato da --api", api != "" and client.base_url == api, + "base=%s api=%s" % [client.base_url, api]) + check("lettura e invio usano nodi HTTPRequest distinti", client._get_request != client._post_request) + check("il profilo ha un player_id di 32 caratteri e un nome", + str(world.profile["player_id"]).length() == 32 and str(world.profile["name"]) != "") + var submission := Profile.new_submission_id() + check("il submission_id ha formato uuid v4", + submission.length() == 36 and submission.substr(14, 1) == "4" and submission.count("-") == 4) + + # --- classifica vuota (la risposta puo' essere gia' arrivata prima dei segnali) --- + client.leaderboard_ready.connect(_on_ready) + client.leaderboard_failed.connect(_on_failed) + client.fetch() + await _wait_for(func(): return received, 60) + check("la classifica vuota viene letta senza errori", received and rows.is_empty()) + + # --- due letture ravvicinate non devono fallire --- + fetch_failures = 0 + client.fetch() + client.fetch() + await _wait_for(func(): return fetched_twice_done(), 40) + check("due letture ravvicinate non generano errori di richiesta in corso", fetch_failures == 0, + "errori=%d" % fetch_failures) + + # --- invio e posizione --- + client.score_submitted.connect(_on_submitted) + client.submit("Tester", 1500) + await _wait_for(func(): return submitted, 60) + check("l'invio restituisce posizione, id della riga e totale", + submitted and int(info.get("rank", 0)) == 1 and int(info.get("total", 0)) == 1 + and str(info.get("id", "")).length() == 36, str(info)) + + # --- secondo giocatore: la classifica si ordina --- + var second = Leaderboard.new() + second.player_id = "player-second-0001" + root.add_child(second) + second.score_submitted.connect(_on_submitted2) + second.submit("Secondo", 3000) + await _wait_for(func(): return submitted2, 60) + check("il secondo invio finisce in testa alla classifica", int(info2.get("rank", 0)) == 1) + received = false + client.fetch() + await _wait_for(func(): return received, 60) + check("la classifica contiene due partite ordinate", + rows.size() == 2 and int(rows[0]["score"]) == 3000 and int(rows[1]["score"]) == 1500, str(rows)) + + # --- la schermata iniziale mostra righe e riassunto personale --- + var screen = world.start_screen + # Il campo del nome c'e' solo se il nome non e' gia' stato scelto (profilo locale). + check("la schermata iniziale mostra il nome del giocatore", + screen != null and (screen.name_field != null or screen.name_locked == true), + "campo=%s bloccato=%s" % [str(screen.name_field if screen else null), str(screen.name_locked if screen else null)]) + screen.show_leaderboard(rows, 2, {"best": 1500, "rank": 2, "games": 1}) + check("le righe finiscono nella lista della schermata", screen.rows_box.get_child_count() >= 2, + "righe=%d" % screen.rows_box.get_child_count()) + check("il riassunto personale e' compilato", "1500" in screen.me_label.text) + + # --- l'invio parte una sola volta alla morte (percorso reale: il giocatore muore) --- + client.score_submitted.connect(func(_data): submit_events += 1) + world.player.set_physics_process(false) + world.player.take_damage(100000) # morte vera: costruisce l'overlay e invia + world.player.take_damage(100000) # gia' morto: ignorato + world._on_player_died() # rete di sicurezza: non deve inviare di nuovo + await create_timer(1.5).timeout + check("alla morte si invia una sola volta", world.score_submitted and submit_events == 1, + "eventi=%d" % submit_events) + check("la schermata di morte mostra l'esito dell'invio", + is_instance_valid(world.player.death_note) and world.player.death_note.text != "", + world.player.death_note.text) + + # --- servizio irraggiungibile: il gioco resta vivo --- + var broken = Leaderboard.new() + broken.player_id = "player-offline-0001" + root.add_child(broken) # prima nella scena: _ready() risolve l'url + broken.base_url = "http://127.0.0.1:1" + Leaderboard.API_PATH # poi lo si forza + broken.leaderboard_failed.connect(func(reason): offline_reason = reason) + broken.fetch() + await _wait_for(func(): return offline_reason != "", 60) + check("servizio irraggiungibile segnalato senza bloccare il gioco", offline_reason != "", offline_reason) + + print("%d controlli superati, %d falliti" % [14 - failures, failures]) + world.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + +func fetched_twice_done() -> bool: + return fetch_failures == 0 and received + +func _wait_for(condition: Callable, steps: int) -> void: + for i in steps: + if condition.call(): + return + await create_timer(0.1).timeout + +func _on_ready(list: Array, _total: int, _me: Dictionary) -> void: + rows = list + received = true + +func _on_failed(_reason: String) -> void: + fetch_failures += 1 + +func _on_submitted(data: Dictionary) -> void: + info = data + submitted = true + +func _on_submitted2(data: Dictionary) -> void: + info2 = data + submitted2 = true diff --git a/tools/test_leaderboard.gd.uid b/tools/test_leaderboard.gd.uid new file mode 100644 index 0000000..ae3b9ed --- /dev/null +++ b/tools/test_leaderboard.gd.uid @@ -0,0 +1 @@ +uid://byg22av0uhqs8 diff --git a/tools/test_leaderboard.sh b/tools/test_leaderboard.sh new file mode 100755 index 0000000..1772376 --- /dev/null +++ b/tools/test_leaderboard.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Avvia l'API su un database temporaneo e fa girare il test Godot del client. +set -e +ROOT=$(cd "$(dirname "$0")/.." && pwd) +GODOT="${GODOT:-$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64}" +PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()') +TMP=$(mktemp -d) +DATABASE_PATH="$TMP/scores.db" PORT="$PORT" BIND=127.0.0.1 POST_PER_HOUR=1000 POST_MIN_INTERVAL=0.1 \ + python3 "$ROOT/services/scores-api/app.py" > "$TMP/api.log" 2>&1 & +API_PID=$! +trap 'kill $API_PID 2>/dev/null || true; rm -rf "$TMP"' EXIT +for i in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then break; fi + sleep 0.2 +done +"$GODOT" --headless --path "$ROOT" --script res://tools/test_leaderboard.gd -- "--api=http://127.0.0.1:$PORT" diff --git a/tools/test_music.gd b/tools/test_music.gd index eb4a535..da37ece 100644 --- a/tools/test_music.gd +++ b/tools/test_music.gd @@ -10,6 +10,7 @@ func _initialize() -> void: func run_test() -> void: var world = load("res://scenes/main.tscn").instantiate() root.add_child(world) + world.start_game("Test", false) # come premere GIOCA, senza toccare il profilo salvato await process_frame var players := get_nodes_in_group("music") assert(players.size() == 1) diff --git a/tools/test_player_profile.gd b/tools/test_player_profile.gd new file mode 100644 index 0000000..92feb92 --- /dev/null +++ b/tools/test_player_profile.gd @@ -0,0 +1,144 @@ +extends SceneTree +## Verifica le regole del profilo locale (nome, id stabile, blocco del nome) su un file +## temporaneo, senza toccare il profilo reale del gioco. +## Avviare con: --headless --path . --script res://tools/test_player_profile.gd +const Profile = preload("res://scripts/player_profile.gd") +const TEST_PATH := "user://test_profile_check.cfg" + +var failures := 0 +var checks := 0 + +func _initialize() -> void: + call_deferred("run_test") + +func check(label: String, condition: bool, detail: String = "") -> void: + checks += 1 + if condition: + print("PASS: " + label) + else: + failures += 1 + print("FAIL: %s %s" % [label, detail]) + +func run_test() -> void: + DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH)) + + # 1) file assente: valori predefiniti, id generato, nome non bloccato + var fresh := Profile.load_profile(TEST_PATH) + check("profilo nuovo: nome predefinito e nome non bloccato", + fresh["name"] == Profile.DEFAULT_NAME and fresh["locked"] == false, + "nome=%s bloccato=%s" % [str(fresh["name"]), str(fresh["locked"])]) + check("profilo nuovo: id del giocatore generato (32 esadecimali)", + fresh["player_id"].length() == 32 and Profile.is_valid_player_id(fresh["player_id"]), + "id=%s" % str(fresh["player_id"])) + + # 2) l'id resta lo stesso a ogni lettura (i punteggi vecchi restano collegati) + var again := Profile.load_profile(TEST_PATH) + check("l'id del giocatore e' stabile tra una lettura e l'altra", + again["player_id"] == fresh["player_id"], "%s vs %s" % [str(again["player_id"]), str(fresh["player_id"])]) + + # 3) nome scelto e punteggio migliore salvati e riletti + var saved := fresh.duplicate() + saved["name"] = "Mat" + saved["best"] = 300 + Profile.save_profile(saved, TEST_PATH) + var reloaded := Profile.load_profile(TEST_PATH) + check("nome e miglior punteggio sopravvivono alla riscrittura", + reloaded["name"] == "Mat" and int(reloaded["best"]) == 300, + "nome=%s best=%s" % [str(reloaded["name"]), str(reloaded["best"])]) + check("un nome scelto blocca il nome (non si richiede piu')", + reloaded["locked"] == true, "bloccato=%s" % str(reloaded["locked"])) + + # 4) il nome scelto e' persistito anche nel file, non solo in memoria + var config := ConfigFile.new() + var wrote := config.load(TEST_PATH) == OK + check("il blocco del nome e' scritto nel file del profilo", + wrote and bool(config.get_value("player", "locked", false)), + "letto=%s" % str(wrote)) + + # 5) profili vecchi (senza il campo locked) con nome personalizzato risultano bloccati + check("profilo vecchio con nome personalizzato: nome considerato scelto", + Profile.is_name_locked({"name": "Vecchio", "player_id": "x"}) == true) + check("profilo vecchio con nome predefinito: nome non considerato scelto", + Profile.is_name_locked({"name": Profile.DEFAULT_NAME, "player_id": "x"}) == false) + + # 6) pulizia del nome secondo le regole del server + check("il nome viene ripulito da apici, emoji e simboli", + Profile.sanitize_name(" Ma't 😀 ") == "Mat b", Profile.sanitize_name(" Ma't 😀 ")) + check("il nome vuoto diventa quello predefinito", + Profile.sanitize_name(" ") == Profile.DEFAULT_NAME, Profile.sanitize_name(" ")) + check("il nome e' limitato a 16 caratteri", + Profile.sanitize_name("abcdefghijklmnopqrstuvwxyz").length() == Profile.NAME_MAX, + Profile.sanitize_name("abcdefghijklmnopqrstuvwxyz")) + check("le lettere accentate sono conservate", + Profile.sanitize_name("Città àèìòù") == "Città àèìòù", Profile.sanitize_name("Città àèìòù")) + + # 7) uuid di partita: forma valida e sempre diverso (idempotenza lato server) + var uuid_a := Profile.new_submission_id() + var uuid_b := Profile.new_submission_id() + var parts := uuid_a.split("-") + check("l'identificativo di partita e' un uuid v4", + parts.size() == 5 and uuid_a.length() == 36 and uuid_a[14] == "4" + and "89ab".contains(uuid_a[19]) and uuid_a != uuid_b, uuid_a) + + # 8) testo per JavaScript: apici e barre non rompono il codice + check("il testo per localStorage e' messo in sicurezza", + Profile.js_escape("a'b\\c\nd") == "a\\'b\\\\c d", Profile.js_escape("a'b\\c\nd")) + + # 9) id del giocatore: le stesse regole del server + check("l'id del giocatore viene validato come il server", + Profile.is_valid_player_id("abcdef0123456789") + and Profile.is_valid_player_id("player-offline-0001") + and not Profile.is_valid_player_id("corto") + and not Profile.is_valid_player_id("con spazio qui") + and not Profile.is_valid_player_id("apostrofo'qui")) + + # 10) valori letti dal browser: quelli assenti NON devono diventare la stringa "null" + var from_browser := {"name": Profile.DEFAULT_NAME, "player_id": "abcdef0123456789", + "best": 0, "locked": false} + Profile.apply_web_json(from_browser, '{"n":null,"i":null,"b":null,"l":null}') + check("valori assenti nel browser non sporcano il profilo (niente \"null\")", + from_browser["name"] == Profile.DEFAULT_NAME and from_browser["locked"] == false + and from_browser["player_id"] == "abcdef0123456789", + "nome=%s bloccato=%s" % [str(from_browser["name"]), str(from_browser["locked"])]) + Profile.apply_web_json(from_browser, '{"n":"Mat","i":"024a956d532357c426ceb08ff86124e1","b":"800","l":"1"}') + check("i valori del browser vengono applicati al profilo", + from_browser["name"] == "Mat" and int(from_browser["best"]) == 800 and from_browser["locked"] == true + and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1", + "nome=%s best=%s" % [str(from_browser["name"]), str(from_browser["best"])]) + Profile.apply_web_json(from_browser, '{"n":"null","i":"corto","b":"molto","l":"1"}') + check("valori non validi dal browser: nome riparato, id e punteggio intatti", + from_browser["name"] == Profile.DEFAULT_NAME and from_browser["locked"] == false + and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1" + and int(from_browser["best"]) == 800, + "nome=%s id=%s best=%s bloccato=%s" % [str(from_browser["name"]), + str(from_browser["player_id"]), str(from_browser["best"]), str(from_browser["locked"])]) + check("testo di un valore nullo: stringa vuota, non \"null\"", + Profile.text_of(null) == "" and Profile.text_of("x") == "x", Profile.text_of(null)) + + # 11) riparazione automatica: un profilo sporcato con la stringa "null" + # (bug di una versione precedente) torna al nome predefinito e si sblocca. + var junk_path := "user://test_profile_junk.cfg" + var junk := ConfigFile.new() + junk.set_value("player", "name", "null") + junk.set_value("player", "player_id", "abcdef0123456789") + junk.set_value("player", "best", 500) + junk.set_value("player", "locked", true) + junk.save(junk_path) + var repaired := Profile.load_profile(junk_path) + check("un profilo con nome \"null\" viene riparato (nome predefinito, sbloccato)", + repaired["name"] == Profile.DEFAULT_NAME and repaired["locked"] == false + and int(repaired["best"]) == 500, + "nome=%s bloccato=%s best=%s" % [str(repaired["name"]), str(repaired["locked"]), + str(repaired["best"])]) + var junk_after := ConfigFile.new() + junk_after.load(junk_path) + check("la riparazione viene riscritta nel file", + str(junk_after.get_value("player", "name", "")) == Profile.DEFAULT_NAME + and bool(junk_after.get_value("player", "locked", true)) == false, + "%s / %s" % [str(junk_after.get_value("player", "name", "")), + str(junk_after.get_value("player", "locked", true))]) + DirAccess.remove_absolute(ProjectSettings.globalize_path(junk_path)) + + DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH)) + print("%d controlli superati, %d falliti" % [checks - failures, failures]) + quit(1 if failures > 0 else 0) diff --git a/tools/test_player_profile.gd.uid b/tools/test_player_profile.gd.uid new file mode 100644 index 0000000..ab4d108 --- /dev/null +++ b/tools/test_player_profile.gd.uid @@ -0,0 +1 @@ +uid://hc53c30p8unw diff --git a/tools/test_scores_api.py b/tools/test_scores_api.py new file mode 100644 index 0000000..c23d8cc --- /dev/null +++ b/tools/test_scores_api.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Test dell'API della classifica: avvia il servizio su un database temporaneo +(nessun Docker richiesto, gira con python3) e verifica i casi limite. + + python3 tools/test_scores_api.py +""" +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "services/scores-api/app.py" +PASSED = [] + + +def check(label: str, condition: bool, detail: str = "") -> None: + if condition: + PASSED.append(label) + print("PASS: %s" % label) + else: + print("FAIL: %s %s" % (label, detail)) + raise SystemExit(1) + + +def free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def call(url: str, method: str = "GET", payload=None, headers=None, raw=None, timeout=5.0): + data = raw if raw is not None else (json.dumps(payload).encode() if payload is not None else None) + request = urllib.request.Request(url, data=data, method=method) + for key, value in (headers or {}).items(): + request.add_header(key, value) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read().decode() + return response.status, (json.loads(body) if body else {}) + except urllib.error.HTTPError as error: + body = error.read().decode() + try: + return error.code, json.loads(body) if body else {} + except json.JSONDecodeError: + return error.code, {"raw": body} + + +def start_service(db_path: Path, port: int, extra_env=None): + env = dict(os.environ, DATABASE_PATH=str(db_path), PORT=str(port), BIND="127.0.0.1") + env.update(extra_env or {}) + process = subprocess.Popen([sys.executable, str(APP)], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + base = "http://127.0.0.1:%d" % port + for _ in range(100): + try: + if call(base + "/health", timeout=0.5)[0] == 200: + return process, base + except Exception: + time.sleep(0.1) + process.kill() + raise SystemExit("il servizio non si e' avviato:\n" + process.stdout.read()) + + +def stop(process) -> None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + +def submission(player: str, name: str, score: int, submission_id: str | None = None) -> dict: + return {"player_id": player, "name": name, "score": score, + "submission_id": submission_id or str(uuid.uuid4())} + + +def main() -> None: + workdir = Path(tempfile.mkdtemp(prefix="zombie-scores-")) + db = workdir / "scores.db" + port = free_port() + fast = {"POST_PER_HOUR": "1000", "POST_MIN_INTERVAL": "0.3", "GET_PER_MINUTE": "1000"} + process, base = start_service(db, port, fast) + json_header = {"Content-Type": "application/json"} + try: + # --- salute e classifica vuota --- + status, health = call(base + "/health") + check("health risponde 200 con nome e versione", + status == 200 and health["ok"] and health["service"] == "zombie-fps-scores" and health["rows"] == 0) + status, board = call(base + "/scores") + check("classifica vuota valida", status == 200 and board["top"] == [] and board["total"] == 0 and board["me"] is None) + + # --- invio valido --- + alice, bob = "player-alice-0001", "player-bob-0002" + first_payload = submission(alice, "Alice", 1250) + status, first = call(base + "/scores", "POST", first_payload, json_header) + check("invio valido risponde 201 con id, posizione e totale", + status == 201 and first["ok"] and first["rank"] == 1 and first["total"] == 1 + and len(first["id"]) == 36 and not first["duplicate"]) + identifier = first["id"] + + # --- idempotenza: il retry dello STESSO invio non duplica e non e' bloccato --- + # (caso reale: il client ritenta dopo un timeout, subito dopo il primo invio) + status, again = call(base + "/scores", "POST", first_payload, json_header) + check("retry con lo stesso submission_id e' idempotente", + status == 200 and again["duplicate"] and again["id"] == identifier and again["total"] == 1) + + # --- un id diverso, stesso giocatore: seconda partita --- + time.sleep(0.35) # intervallo minimo fra invii dello stesso giocatore + status, second = call(base + "/scores", "POST", submission(alice, "Alice", 800), json_header) + check("seconda partita dello stesso giocatore viene salvata", + status == 201 and second["total"] == 2 and second["rank"] == 2) + + # --- classifica: tutte le partite, ordinate --- + time.sleep(0.35) + call(base + "/scores", "POST", submission(bob, "Bob", 3000), json_header) + status, board = call(base + "/scores?limit=10&player=" + alice) + names = [row["name"] for row in board["top"]] + scores = [row["score"] for row in board["top"]] + ranks = [row["rank"] for row in board["top"]] + check("classifica mostra tutte le partite in ordine di punteggio", + status == 200 and names == ["Bob", "Alice", "Alice"] and scores == [3000, 1250, 800] and ranks == [1, 2, 3]) + check("il riassunto personale riporta miglior punteggio, posizione e partite", + board["me"]["best"] == 1250 and board["me"]["rank"] == 2 and board["me"]["games"] == 2) + status, limited = call(base + "/scores?limit=2") + check("limit viene rispettato", len(limited["top"]) == 2 and limited["limit"] == 2) + status, huge = call(base + "/scores?limit=9999") + check("limit viene limitato al massimo", huge["limit"] == 50) + status, bad_limit = call(base + "/scores?limit=abc") + check("limit non numerico viene rifiutato", status == 400 and "limit" in bad_limit["error"]) + + # --- validazione --- + cases = [ + ("nome vuoto rifiutato", submission(alice, " ", 100), 400), + ("nome troppo lungo rifiutato", submission(alice, "x" * 17, 100), 400), + ("caratteri strani nel nome rifiutati", submission(alice, "Anna