Classifica globale: API dei punteggi, schermata iniziale e nome memorizzato
- services/scores-api: microservizio (solo stdlib) con SQLite WAL, idempotenza per submission_id, limiti di frequenza e validazione; immagine Docker non-root. - nginx: reverse proxy su /api/zombie-fps/ (stessa origine del gioco, niente CORS). - scripts/leaderboard.gd: client HTTP con retry idempotente; su desktop la classifica resta disattivata senza --api=, cosi' i test non sporcano la classifica pubblica. - scripts/player_profile.gd: nome, id anonimo e miglior punteggio in user:// e in window.localStorage; il nome scelto non viene piu' richiesto; riparazione dei profili con la stringa "null". - scripts/start_screen.gd: menu con nome, GIOCA e classifica; la partita parte solo dal pulsante (via la rete di sicurezza sull'input, Invio nel campo non avvia). - scripts/main.gd, player.gd: avvio esplicito, segnale died, nota dell'invio. - tools: test_scores_api.py (42), test_leaderboard (14), test_player_profile (21), test_start_screen (10); suite esistenti adeguate all'avvio manuale. - docs/HIGHSCORES.md + docs/WEB-DEPLOY.md aggiornati.
This commit is contained in:
@@ -0,0 +1,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()
|
||||
Reference in New Issue
Block a user