Files
zombie-fps/scripts/leaderboard.gd
T
enne2 ae32ad9615 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.
2026-10-13 23:26:00 +02:00

171 lines
6.0 KiB
GDScript

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()