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
+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