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:
enne2
2026-10-13 23:26:00 +02:00
parent 9d1988c48b
commit ae32ad9615
27 changed files with 2071 additions and 32 deletions
+221
View File
@@ -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`) |
+17
View File
@@ -1,5 +1,22 @@
# Web build and compressed deployment # 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 ## Eight windows release: fbfc9bdc01e2b32d
Two windows per wall (eight total) and random zombie entry from different openings, see Two windows per wall (eight total) and random zombie entry from different openings, see
+1
View File
@@ -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/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/canvas_resize_policy=2
html/focus_canvas_on_start=true html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=true
progressive_web_app/enabled=false progressive_web_app/enabled=false
+170
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://dqu86xp3orxdv
+74 -32
View File
@@ -2,6 +2,9 @@ extends Node3D
const PlayerScript = preload("res://scripts/player.gd") const PlayerScript = preload("res://scripts/player.gd")
const ZombieScript = preload("res://scripts/zombie.gd") const ZombieScript = preload("res://scripts/zombie.gd")
const RoomScript = preload("res://scripts/room.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_PATH := "res://assets/audio/music/zombie_industrial_loop.ogg"
const MUSIC_VOLUME_DB := -11.0 const MUSIC_VOLUME_DB := -11.0
const TouchControls = preload("res://scripts/touch_controls.gd") const TouchControls = preload("res://scripts/touch_controls.gd")
@@ -18,12 +21,15 @@ var alive := 0
var spawning := false var spawning := false
var wave_pending := false var wave_pending := false
var smoke := false var smoke := false
var awaiting_web_start := false
var start_label: Label
var last_window := -1 var last_window := -1
var leaderboard: Node
var start_screen: CanvasLayer
var profile := {}
var score_submitted := false
func _ready() -> void: func _ready() -> void:
smoke = "--smoke" in OS.get_cmdline_user_args() smoke = "--smoke" in OS.get_cmdline_user_args()
profile = Profile.load_profile()
room = Node3D.new() room = Node3D.new()
room.set_script(RoomScript) room.set_script(RoomScript)
add_child(room) add_child(room)
@@ -74,44 +80,80 @@ func _ready() -> void:
cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER) cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
_setup_music() _setup_music()
_setup_touch() _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"): if OS.has_feature("web"):
awaiting_web_start = true
if touch != null: if touch != null:
touch.enabled = false # il primo tocco/click avvia la partita touch.enabled = false # i tocchi servono alla schermata iniziale
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()
if smoke: if smoke:
get_tree().create_timer(12).timeout.connect(func(): get_tree().create_timer(12).timeout.connect(func():
print("SMOKE: wave=", wave, " alive=", alive, " player_hp=", player.hp) print("SMOKE: wave=", wave, " alive=", alive, " player_hp=", player.hp)
get_tree().quit()) get_tree().quit())
func _unhandled_input(event: InputEvent) -> void: ## Il pulsante GIOCA della schermata iniziale delega qui: sblocca l'audio (policy dei
if not awaiting_web_start: ## 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 return
var started := false score_submitted = true
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if int(score) > int(profile.get("best", 0)):
started = true profile["best"] = score
elif event is InputEventScreenTouch and event.pressed: Profile.save_profile(profile)
started = true # primo tocco su mobile player.set_death_note("invio del punteggio in corso...")
if started: leaderboard.submit(profile["name"], score)
awaiting_web_start = false
start_label.queue_free() func _on_score_submitted(info: Dictionary) -> void:
if not player.touch_mode: var total := int(info.get("total", 0))
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED player.set_death_note("PUNTEGGIO %d — %d° su %d partite" % [score, int(info.get("rank", 0)), total])
if touch != null: leaderboard.fetch()
touch.enabled = true
# Il browser concede l'audio solo dopo un gesto dell'utente: qui il click/tocco. func _on_submit_failed(reason: String) -> void:
_start_music() player.set_death_note("punteggio non inviato (%s)" % reason)
_next_wave()
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 ## 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. ## --touch / --no-touch sulla riga di comando e con ?touch=1 / ?touch=0 sul Web.
+23
View File
@@ -1,4 +1,5 @@
extends CharacterBody3D extends CharacterBody3D
signal died
var camera: Camera3D var camera: Camera3D
var damage_pivot: Node3D var damage_pivot: Node3D
var damage_feedback: CanvasLayer var damage_feedback: CanvasLayer
@@ -81,6 +82,14 @@ func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
var restart_button: Button 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: func _input(event: InputEvent) -> void:
# Touch nativo: funziona anche con emulate_mouse_from_touch disattivato. # Touch nativo: funziona anche con emulate_mouse_from_touch disattivato.
@@ -248,6 +257,20 @@ func _die() -> void:
layer.add_child(message) layer.add_child(message)
message.set_anchors_and_offsets_preset(Control.PRESET_CENTER) message.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
tween.tween_property(message, "modulate:a", 1.0, 0.5).set_delay(2.2) 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: if touch_mode:
var restart := Button.new() var restart := Button.new()
restart_button = restart restart_button = restart
+207
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://2o00kx51f760
+198
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
uid://djsrmu6t131d
+3
View File
@@ -0,0 +1,3 @@
data/
*.pyc
__pycache__/
+18
View File
@@ -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"]
+337
View File
@@ -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()
+35
View File
@@ -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"
+24
View File
@@ -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;
}
+1
View File
@@ -5,6 +5,7 @@ func _initialize() -> void:
func run_test() -> void: func run_test() -> void:
world = load("res://scenes/main.tscn").instantiate() world = load("res://scenes/main.tscn").instantiate()
root.add_child(world) root.add_child(world)
world.start_game("Test", false) # come premere GIOCA, senza toccare il profilo salvato
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await create_timer(2.4).timeout await create_timer(2.4).timeout
var zombie: Node3D var zombie: Node3D
+150
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://byg22av0uhqs8
+16
View File
@@ -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"
+1
View File
@@ -10,6 +10,7 @@ func _initialize() -> void:
func run_test() -> void: func run_test() -> void:
var world = load("res://scenes/main.tscn").instantiate() var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world) root.add_child(world)
world.start_game("Test", false) # come premere GIOCA, senza toccare il profilo salvato
await process_frame await process_frame
var players := get_nodes_in_group("music") var players := get_nodes_in_group("music")
assert(players.size() == 1) assert(players.size() == 1)
+144
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://hc53c30p8unw
+263
View File
@@ -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()
+161
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://b0c07ki7hr8mm
+1
View File
@@ -4,6 +4,7 @@ func _initialize() -> void:
func run_test() -> void: func run_test() -> void:
var world = load("res://scenes/main.tscn").instantiate() var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world) root.add_child(world)
world.start_game("Test", false) # come premere GIOCA, senza toccare il profilo salvato
world.player.hp = 100000 world.player.hp = 100000
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await create_timer(5.2).timeout await create_timer(5.2).timeout