- 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.
171 lines
6.0 KiB
GDScript
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()
|