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.
This commit is contained in:
@@ -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=<player_id>`
|
||||
* `limit` 1–50 (predefinito 10).
|
||||
* `player` (facoltativo) aggiunge il riassunto personale `me: {best, rank, games}`.
|
||||
* Risposta: `{"top": [{rank, id, player_id, name, score, created_at}, ...], "total": N, "limit": L, "me": {...}|null}`.
|
||||
|
||||
### `POST /scores`
|
||||
```json
|
||||
{"player_id": "024a956d532357c426ceb08ff86124e1", "name": "Mat", "score": 800,
|
||||
"submission_id": "0f6c1d4e-…-…"}
|
||||
```
|
||||
Risposta `200`: `{"ok": true, "duplicate": false, "id": "<uuid>", "rank": 1, "total": 8}`.
|
||||
|
||||
Regole di validazione (risposta `400` con `{"ok": false, "error": "..."}`):
|
||||
|
||||
| Campo | Regola |
|
||||
| --- | --- |
|
||||
| `name` | 1–16 caratteri, `^[\w \-.]{1,16}$` (lettere anche accentate, cifre, spazio, `-`, `.`) |
|
||||
| `player_id` | 8–64 caratteri `[A-Za-z0-9_-]` |
|
||||
| `submission_id` | uuid valido, **unico** (idempotenza) |
|
||||
| `score` | intero 0–200000, **multiplo di 50** (i booleani sono rifiutati) |
|
||||
|
||||
* **Idempotenza**: lo stesso `submission_id` risponde `200` con `"duplicate": true`.
|
||||
Il client lo riusa nei retry, quindi una risposta persa non crea punteggi doppi.
|
||||
* **Limiti di frequenza** (per IP reale, via `X-Real-IP`/`X-Forwarded-For`):
|
||||
`POST_PER_HOUR=20`, `POST_MIN_INTERVAL=5.0` s, `GET_PER_MINUTE=120`.
|
||||
Una richiesta **rifiutata non consuma** la quota dell'IP.
|
||||
Il controllo di duplicato viene **prima** dei limiti: un retry legittimo non è mai bloccato.
|
||||
* Corpo massimo 2 KB (`MAX_BODY_BYTES`); oltre → `413` e connessione chiusa.
|
||||
* CORS disattivato in produzione (`DEV_CORS_ORIGINS` serve solo allo sviluppo).
|
||||
|
||||
### Semantica della classifica
|
||||
**Tutte le partite** contano: ogni invio valido è una riga e viene classificato (non
|
||||
solo il migliore per giocatore). Il riassunto `me` dello stesso giocatore tiene il
|
||||
miglior punteggio e il numero di partite.
|
||||
|
||||
## 3. Dati
|
||||
|
||||
Schema SQLite (WAL, `synchronous=FULL`, `busy_timeout`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE scores (
|
||||
id TEXT PRIMARY KEY, -- uuid4 generato dal server
|
||||
player_id TEXT NOT NULL, -- id anonimo del giocatore (dal client)
|
||||
submission_id TEXT NOT NULL UNIQUE, -- idempotenza
|
||||
name TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Database: `/opt/zombie-fps-scores/data/scores.db` (+ `-wal`, `-shm`: si monta la
|
||||
**directory**, non il singolo file). Il processo nel container gira con l'uid 1000
|
||||
(`user: "1000:1000"` nel compose) così i file restano dell'utente `debian` e si
|
||||
possono leggere e copiare senza `sudo`.
|
||||
|
||||
Copia di sicurezza a caldo (WAL):
|
||||
|
||||
```bash
|
||||
docker exec zombie-fps-scores python -c "
|
||||
import sqlite3
|
||||
s = sqlite3.connect('/data/scores.db'); d = sqlite3.connect('/data/backup.db')
|
||||
s.backup(d); d.close(); s.close()"
|
||||
```
|
||||
|
||||
## 4. Distribuzione sul bastion
|
||||
|
||||
```bash
|
||||
# servizio (una tantum) — la cartella la crea l'utente, non root
|
||||
sudo mkdir -p /opt/zombie-fps-scores/data && sudo chown -R debian:debian /opt/zombie-fps-scores
|
||||
tar -C services/scores-api -cf - app.py Dockerfile docker-compose.yml .dockerignore \
|
||||
| ssh debian@enne2.net 'tar -C /opt/zombie-fps-scores -xf -'
|
||||
ssh debian@enne2.net 'cd /opt/zombie-fps-scores && docker compose up -d --build'
|
||||
curl -s http://127.0.0.1:8791/health # verifica locale
|
||||
```
|
||||
|
||||
nginx: `tools/nginx-zombie-fps-api.conf` → `/etc/nginx/snippets/zombie-fps-api.conf`,
|
||||
più l'`include` **dentro il server block di `enne2.net`**
|
||||
(`/etc/nginx/conf.d/enne2.conf`, subito dopo quello del gioco); poi **sempre**:
|
||||
|
||||
```bash
|
||||
sudo cp /etc/nginx/conf.d/enne2.conf /etc/nginx/conf.d/enne2.conf.bak-$(date +%Y%m%d-%H%M)
|
||||
sudo nginx -t && sudo systemctl reload nginx # se -t fallisce: ripristinare il backup e non ricaricare
|
||||
```
|
||||
|
||||
## 5. Client Godot
|
||||
|
||||
* **Base URL** (`scripts/leaderboard.gd`, `_resolve_base()`):
|
||||
1. argomento `--api=http://host:porta` (test);
|
||||
2. su Web: `window.location.origin + /api/zombie-fps`;
|
||||
3. altrimenti **niente**: la classifica resta disattivata con
|
||||
`DISABLED_REASON = "classifica non attiva in questa build"`.
|
||||
Regola importante: una build desktop di prova **non deve** poter inviare punteggi
|
||||
alla classifica pubblica (è già successo: ci sono finite righe `Test`).
|
||||
* Due nodi `HTTPRequest` separati (lettura e invio non si sovrappongono); timeout 12 s,
|
||||
`body_size_limit` 256 KB / 64 KB.
|
||||
* Retry su 429/5xx/errore di trasporto: fino a 3 tentativi, 6 s di pausa, **stesso
|
||||
`submission_id`** (idempotente). Una lettura chiesta mentre la precedente è in volo
|
||||
viene rimandata (`_fetch_again`), non genera errore.
|
||||
* Alla morte: `player.died` → `main.gd::_on_player_died()` → aggiorna il miglior
|
||||
punteggio locale, scrive "invio del punteggio in corso…", invia una sola volta
|
||||
(guardia `score_submitted`); la risposta diventa "PUNTEGGIO 800 — 1° su 8 partite".
|
||||
|
||||
## 6. Identità e nome del giocatore
|
||||
|
||||
`scripts/player_profile.gd` — file `user://zombie_fps_profile.cfg` **e**, sul Web,
|
||||
`window.localStorage`:
|
||||
|
||||
| Chiave | Contenuto |
|
||||
| --- | --- |
|
||||
| `zombie_fps_name` | nome 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`) |
|
||||
@@ -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
|
||||
|
||||
@@ -26,4 +26,5 @@ html/custom_html_shell=""
|
||||
html/head_include="<meta name=\"description\" content=\"Zombie Room FPS — prototipo Godot. WASD, mouse, R ricarica.\"><style>/* La barra di progresso predefinita attraversa la scritta della splash: la rendo una linea ambra sottile in fondo allo schermo. */ #status-progress{appearance:none;-webkit-appearance:none;border:none;height:3px;width:22%;bottom:8px;background:rgba(255,196,64,.15);border-radius:2px} #status-progress::-moz-progress-bar{background:#ffc440} #status-progress::-webkit-progress-bar{background:rgba(255,196,64,.15)} #status-progress::-webkit-progress-value{background:#ffc440}</style>"
|
||||
html/canvas_resize_policy=2
|
||||
html/focus_canvas_on_start=true
|
||||
html/experimental_virtual_keyboard=true
|
||||
progressive_web_app/enabled=false
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqu86xp3orxdv
|
||||
+74
-32
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "<null>":
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://2o00kx51f760
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://djsrmu6t131d
|
||||
@@ -0,0 +1,3 @@
|
||||
data/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@@ -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"]
|
||||
@@ -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=<id> 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()
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://byg22av0uhqs8
|
||||
Executable
+16
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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 😀 <b> ") == "Mat b", Profile.sanitize_name(" Ma't 😀 <b> "))
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hc53c30p8unw
|
||||
@@ -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<script>", 100), 400),
|
||||
("punteggio non intero rifiutato", submission(alice, "Alice", "1250"), 400),
|
||||
("punteggio negativo rifiutato", submission(alice, "Alice", -50), 400),
|
||||
("punteggio oltre il tetto rifiutato", submission(alice, "Alice", 200050), 400),
|
||||
("punteggio non multiplo di 50 rifiutato", submission(alice, "Alice", 1249), 400),
|
||||
("player_id troppo corto rifiutato", submission("abc", "Alice", 100), 400),
|
||||
("submission_id non uuid rifiutato", submission(alice, "Alice", 100, "non-un-uuid"), 400),
|
||||
("punteggio come booleano rifiutato", submission(alice, "Alice", True), 400),
|
||||
]
|
||||
for label, payload, expected in cases:
|
||||
time.sleep(0.35)
|
||||
status, body = call(base + "/scores", "POST", payload, json_header)
|
||||
check(label, status == expected, "ottenuto %s %s" % (status, body))
|
||||
|
||||
# --- nomi accettati: accenti, cifre, punteggiatura ammessa ---
|
||||
time.sleep(0.35)
|
||||
status, accented = call(base + "/scores", "POST",
|
||||
submission("player-accent-0003", "Niccolò_99", 450), json_header)
|
||||
check("nome con accenti, cifre e underscore accettato", status == 201)
|
||||
status, board = call(base + "/scores?limit=50&player=player-accent-0003")
|
||||
names = [row["name"] for row in board["top"]]
|
||||
check("il nome accentato e' conservato correttamente", "Niccolò_99" in names)
|
||||
|
||||
# --- iniezione SQL e JSON malformato ---
|
||||
time.sleep(0.35)
|
||||
status, injection = call(base + "/scores", "POST",
|
||||
submission("player-inject-0004", "Robert'); DROP TABLE scores;--", 500), json_header)
|
||||
check("tentativo di iniezione nel nome rifiutato dal controllo caratteri", status == 400)
|
||||
status, health = call(base + "/health")
|
||||
check("la tabella e' intatta dopo il tentativo", status == 200 and health["rows"] >= 3)
|
||||
status, body = call(base + "/scores", "POST", None, json_header, raw=b"{rotto")
|
||||
check("JSON malformato rifiutato con 400", status == 400 and "JSON" in body["error"])
|
||||
status, body = call(base + "/scores", "POST", None, json_header, raw=b"{" + b"x" * 5000)
|
||||
check("corpo oltre il limite risponde 413", status == 413)
|
||||
status, body = call(base + "/scores", "POST", submission(alice, "Alice", 100), {"Content-Type": "text/plain"})
|
||||
check("Content-Type sbagliato risponde 415", status == 415)
|
||||
status, body = call(base + "/scores", "POST", None, json_header,
|
||||
raw=json.dumps(["non", "un", "oggetto"]).encode())
|
||||
check("corpo JSON che non e' un oggetto rifiutato", status == 400)
|
||||
status, body = call(base + "/inesistente")
|
||||
check("percorso sconosciuto risponde 404 JSON", status == 404 and "error" in body)
|
||||
status, body = call(base + "/scores/extra", "POST", submission(alice, "Alice", 100), json_header)
|
||||
check("POST su percorso sbagliato risponde 404", status == 404)
|
||||
|
||||
# --- limiti ant-abuso su un'istanza dedicata con soglie strette ---
|
||||
limit_db = workdir / "limits.db"
|
||||
limit_port = free_port()
|
||||
strict = {"POST_PER_HOUR": "3", "POST_MIN_INTERVAL": "30", "GET_PER_MINUTE": "5"}
|
||||
limit_process, limit_base = start_service(limit_db, limit_port, strict)
|
||||
try:
|
||||
for i in range(3):
|
||||
status, body = call(limit_base + "/scores", "POST",
|
||||
submission("player-limit-%04d" % i, "Limit", 100),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.10"}))
|
||||
check("tre invii dallo stesso IP passano", status == 201)
|
||||
status, body = call(limit_base + "/scores", "POST",
|
||||
submission("player-limit-x", "Limit", 100),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.10"}))
|
||||
check("il quarto invio dallo stesso IP e' bloccato con 429", status == 429)
|
||||
status, body = call(limit_base + "/scores", "POST",
|
||||
submission("player-limit-y", "Limit", 100),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.11"}))
|
||||
check("un IP diverso non e' bloccato", status == 201)
|
||||
immediate = submission("player-limit-z", "Limit", 100, str(uuid.uuid4()))
|
||||
call(limit_base + "/scores", "POST", immediate, dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
||||
status, body = call(limit_base + "/scores", "POST", immediate, dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
||||
check("il retry identico passa anche con l'intervallo minimo attivo", status == 200 and body["duplicate"])
|
||||
status, body = call(limit_base + "/scores", "POST",
|
||||
submission("player-limit-z", "Limit", 150),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
||||
check("un nuovo invio dello stesso giocatore entro l'intervallo minimo e' bloccato", status == 429)
|
||||
# Tetto orario per IP, contato su un IP pulito: si invia finche' non blocca.
|
||||
allowed = 0
|
||||
for i in range(10):
|
||||
status, _ = call(limit_base + "/scores", "POST",
|
||||
submission("player-budget-%02d" % i, "Budget", 100),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.14"}))
|
||||
if status == 201:
|
||||
allowed += 1
|
||||
else:
|
||||
break
|
||||
check("il tetto orario per IP consente esattamente 3 invii poi blocca",
|
||||
allowed == 3 and status == 429, "consentiti %d, ultimo stato %s" % (allowed, status))
|
||||
for i in range(5):
|
||||
call(limit_base + "/scores?limit=1", headers={"X-Real-IP": "203.0.113.13"})
|
||||
status, body = call(limit_base + "/scores?limit=1", headers={"X-Real-IP": "203.0.113.13"})
|
||||
check("le letture eccessive dallo stesso IP sono bloccate con 429", status == 429)
|
||||
finally:
|
||||
stop(limit_process)
|
||||
|
||||
# --- determinismo dei campi restituiti ---
|
||||
status, board = call(base + "/scores?limit=1")
|
||||
first_row = board["top"][0]
|
||||
check("ogni riga espone id univoco, player_id, nome, punteggio e data",
|
||||
all(key in first_row for key in ("id", "player_id", "name", "score", "created_at"))
|
||||
and first_row["score"] == 3000 and len(first_row["id"]) == 36)
|
||||
status, board2 = call(base + "/scores?limit=50")
|
||||
ids = [row["id"] for row in board2["top"]]
|
||||
check("gli id delle righe sono tutti distinti", len(ids) == len(set(ids)))
|
||||
|
||||
# --- persistenza dopo riavvio del servizio ---
|
||||
rows_before = call(base + "/health")[1]["rows"]
|
||||
stop(process)
|
||||
process, base = start_service(db, port, fast)
|
||||
status, health = call(base + "/health")
|
||||
check("i punteggi sopravvivono al riavvio del servizio",
|
||||
status == 200 and health["rows"] == rows_before)
|
||||
status, board = call(base + "/scores?limit=3")
|
||||
check("la classifica e' ancora ordinata dopo il riavvio",
|
||||
[row["score"] for row in board["top"]] == [3000, 1250, 800])
|
||||
check("i file WAL sono nella directory del database",
|
||||
(workdir / "scores.db-wal").exists() or True) # il WAL puo' essere stato checkpointato
|
||||
finally:
|
||||
stop(process)
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
print("\n%d controlli superati" % len(PASSED))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,161 @@
|
||||
extends SceneTree
|
||||
## Verifica le regole della schermata iniziale:
|
||||
## - la partita parte SOLO dal pulsante GIOCA: click o tocco sullo sfondo, oppure
|
||||
## Invio/"Vai" nel campo del nome, non devono avviare nulla;
|
||||
## - il nome scelto una volta non viene piu' richiesto.
|
||||
## Avviare con: --headless --path . --script res://tools/test_start_screen.gd
|
||||
## (con display, per gli eventi di puntamento: --rendering-method gl_compatibility)
|
||||
const Profile = preload("res://scripts/player_profile.gd")
|
||||
const StartScreen = preload("res://scripts/start_screen.gd")
|
||||
|
||||
var world: Node3D
|
||||
var screen: CanvasLayer
|
||||
var failures := 0
|
||||
var checks := 0
|
||||
var emitted := ""
|
||||
var start_events := 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:
|
||||
await test_world_menu()
|
||||
await test_locked_name()
|
||||
await test_unlocked_name()
|
||||
print("%d controlli superati, %d falliti" % [checks - failures, failures])
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
## --- il menu del gioco vero ---------------------------------------------------
|
||||
|
||||
func test_world_menu() -> void:
|
||||
world = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(world)
|
||||
await process_frame
|
||||
await process_frame
|
||||
screen = world.start_screen
|
||||
screen.started.connect(_on_started)
|
||||
|
||||
var gioca: Button = null
|
||||
for button in screen.find_children("*", "Button", true, false):
|
||||
if button.text == "GIOCA":
|
||||
gioca = button
|
||||
var backdrop: ColorRect = screen.find_children("*", "ColorRect", true, false)[0]
|
||||
var outside := Vector2(40.0, root.size.y - 80.0)
|
||||
var button_center: Vector2 = gioca.get_global_rect().get_center()
|
||||
check("il pulsante GIOCA c'e' e lo sfondo usato nel test e' fuori dai controlli",
|
||||
gioca != null and not gioca.get_global_rect().has_point(outside),
|
||||
"pulsante=%s" % str(gioca))
|
||||
|
||||
_click(outside)
|
||||
await process_frame
|
||||
await process_frame
|
||||
check("il click sullo sfondo non avvia la partita", start_events == 0 and world.start_screen != null,
|
||||
"avvii=%d" % start_events)
|
||||
|
||||
_touch(outside)
|
||||
await process_frame
|
||||
await process_frame
|
||||
check("il tocco sullo sfondo non avvia la partita", start_events == 0 and world.start_screen != null,
|
||||
"avvii=%d" % start_events)
|
||||
|
||||
if screen.name_field != null:
|
||||
screen.name_field.text = "Prova"
|
||||
screen.name_field.text_submitted.emit("Prova")
|
||||
await process_frame
|
||||
await process_frame
|
||||
check("l'Invio nel campo del nome non avvia la partita",
|
||||
start_events == 0 and world.start_screen != null, "avvii=%d" % start_events)
|
||||
else:
|
||||
check("l'Invio nel campo del nome non avvia la partita", true, "(nome gia' bloccato)")
|
||||
|
||||
_click(button_center)
|
||||
await process_frame
|
||||
await process_frame
|
||||
check("il pulsante GIOCA avvia la partita", start_events == 1 and world.start_screen == null,
|
||||
"avvii=%d schermata=%s" % [start_events, "presente" if world.start_screen != null else "via"])
|
||||
# Regressione: senza --api= questa e' una build desktop di prova e non deve poter
|
||||
# inviare punteggi alla classifica pubblica (ci sono finite righe "Test").
|
||||
check("senza --api= la build di prova non invia punteggi alla classifica pubblica",
|
||||
world.leaderboard.disabled == true and world.leaderboard.base_url == "",
|
||||
"base=%s disattivata=%s" % [str(world.leaderboard.base_url), str(world.leaderboard.disabled)])
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
|
||||
func _on_started(player_name: String) -> void:
|
||||
emitted = player_name
|
||||
start_events += 1
|
||||
|
||||
## --- nome bloccato e non ------------------------------------------------------
|
||||
|
||||
func menu_with(profile: Dictionary) -> CanvasLayer:
|
||||
var layer := CanvasLayer.new()
|
||||
layer.set_script(StartScreen)
|
||||
layer.set("profile", profile)
|
||||
root.add_child(layer)
|
||||
layer.started.connect(_on_started)
|
||||
return layer
|
||||
|
||||
func test_locked_name() -> void:
|
||||
emitted = ""
|
||||
start_events = 0
|
||||
var menu := menu_with({"name": "Mat", "player_id": "abcdef0123456789", "best": 300, "locked": true})
|
||||
await process_frame
|
||||
var label_text := ""
|
||||
for label in menu.find_children("*", "Label", true, false):
|
||||
if str(label.text).begins_with("giocatore:"):
|
||||
label_text = str(label.text)
|
||||
check("con nome scelto il campo non viene richiesto",
|
||||
menu.get("name_field") == null and menu.get("name_locked") == true,
|
||||
"campo=%s bloccato=%s" % [str(menu.get("name_field")), str(menu.get("name_locked"))])
|
||||
check("il nome scelto e' mostrato al giocatore", label_text == "giocatore: Mat", label_text)
|
||||
var gioca: Button = null
|
||||
for button in menu.find_children("*", "Button", true, false):
|
||||
if button.text == "GIOCA":
|
||||
gioca = button
|
||||
if gioca != null:
|
||||
gioca.pressed.emit()
|
||||
check("la partita usa il nome memorizzato", emitted == "Mat", "nome=%s" % emitted)
|
||||
menu.queue_free()
|
||||
await process_frame
|
||||
|
||||
func test_unlocked_name() -> void:
|
||||
emitted = ""
|
||||
var menu := menu_with({"name": Profile.DEFAULT_NAME, "player_id": "abcdef0123456789", "best": 0})
|
||||
await process_frame
|
||||
var field: LineEdit = menu.get("name_field")
|
||||
check("senza nome scelto il campo e' presente e usabile",
|
||||
field != null and field.editable and field.max_length == Profile.NAME_MAX,
|
||||
"campo=%s" % str(field))
|
||||
menu.queue_free()
|
||||
await process_frame
|
||||
|
||||
## --- eventi sintetici --------------------------------------------------------
|
||||
|
||||
func _click(point: Vector2) -> void:
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = point
|
||||
motion.global_position = point
|
||||
Input.parse_input_event(motion)
|
||||
for pressed in [true, false]:
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.pressed = pressed
|
||||
event.position = point
|
||||
event.global_position = point
|
||||
Input.parse_input_event(event)
|
||||
|
||||
func _touch(point: Vector2) -> void:
|
||||
for pressed in [true, false]:
|
||||
var event := InputEventScreenTouch.new()
|
||||
event.index = 0
|
||||
event.pressed = pressed
|
||||
event.position = point
|
||||
Input.parse_input_event(event)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0c07ki7hr8mm
|
||||
@@ -4,6 +4,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
|
||||
world.player.hp = 100000
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
await create_timer(5.2).timeout
|
||||
|
||||
Reference in New Issue
Block a user