Files
Matteo Benedetto 323a7cdbae Leaderboard attiva di default, senza configurazione
La classifica globale non dipende più da un argomento: sui build desktop il client
usa il servizio pubblico DEFAULT_API_BASE (https://enne2.net/api/zombie-fps).

- scripts/leaderboard.gd: resolve_base() statica con priorità --api=<url> (test)
  → --offline/--no-api (opt-out esplicito per le partite di prova) → origine della
  pagina sul Web → servizio pubblico. Rimosso l'avviso "Classifica disattivata".
- Le suite di test (--script) restano spente a meno di un --api= esplicito, così
  nessun test può scrivere nella classifica pubblica.
- tools/test_start_screen.gd: verifica default attivo, opt-out e override;
  tools/test_rebalance_branding.gd: suite di test senza invii.
- docs/HIGHSCORES.md: nuova regola della base URL e diagnostica aggiornata.

Verifiche: test_start_screen 18/18 PASS con display; test_rebalance_branding
14/14 PASS; test_leaderboard.sh invariato (12 PASS, 2 FAIL preesistenti su questa
macchina anche prima della modifica).
2026-10-24 15:57:42 +02:00

195 lines
7.2 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: servizio pubblico `DEFAULT_API_BASE`, quindi la classifica e' ATTIVA DI
## DEFAULT: non serve nessuna configurazione ne' argomento per leggere e inviare.
## - Override espliciti: `--api=<url>` punta a un altro servizio (test), `--offline`
## (alias `--no-api`) spegne la classifica per le partite di prova.
## - Suite di test (avviate con `--script`): spente, a meno di un `--api=` esplicito,
## cosi' nessun test puo' scrivere 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"
## Servizio pubblico: la classifica funziona senza alcuna configurazione.
const DEFAULT_API_BASE := "https://enne2.net/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 solo quando il servizio e' spento apposta (--offline) o in una suite di test.
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=<url>` (test), `--offline`/`--no-api` (spegne), origine
## della pagina sul Web, altrimenti il servizio pubblico. La classifica e' quindi
## abilitata di default e non dipende da configurazione; `--offline` esiste solo per
## le partite di prova che non devono finire nella classifica pubblica.
## `script_run` e' il guard delle suite di test: non e' configurazione utente, la
## passa `_resolve_base()` quando il processo gira con `--script`.
static func resolve_base(user_args: PackedStringArray, script_run := false) -> String:
for argument in user_args:
if argument.begins_with("--api="):
return argument.substr(6).trim_suffix("/")
for argument in user_args:
if argument == "--offline" or argument == "--no-api":
return ""
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
if script_run:
return "" # suite di test: mai verso il servizio pubblico
return DEFAULT_API_BASE
## Le suite di test girano come script (`--script ...`): in quel caso la classifica
## resta spenta, a meno che il test non abbia passato un suo `--api=`.
static func _is_script_run() -> bool:
for argument in OS.get_cmdline_args():
if argument == "--script" or argument == "-s":
return true
return false
func _resolve_base() -> String:
return resolve_base(OS.get_cmdline_user_args(), _is_script_run())
## 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()