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
+144
View File
@@ -0,0 +1,144 @@
extends SceneTree
## Verifica le regole del profilo locale (nome, id stabile, blocco del nome) su un file
## temporaneo, senza toccare il profilo reale del gioco.
## Avviare con: --headless --path . --script res://tools/test_player_profile.gd
const Profile = preload("res://scripts/player_profile.gd")
const TEST_PATH := "user://test_profile_check.cfg"
var failures := 0
var checks := 0
func _initialize() -> void:
call_deferred("run_test")
func check(label: String, condition: bool, detail: String = "") -> void:
checks += 1
if condition:
print("PASS: " + label)
else:
failures += 1
print("FAIL: %s %s" % [label, detail])
func run_test() -> void:
DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH))
# 1) file assente: valori predefiniti, id generato, nome non bloccato
var fresh := Profile.load_profile(TEST_PATH)
check("profilo nuovo: nome predefinito e nome non bloccato",
fresh["name"] == Profile.DEFAULT_NAME and fresh["locked"] == false,
"nome=%s bloccato=%s" % [str(fresh["name"]), str(fresh["locked"])])
check("profilo nuovo: id del giocatore generato (32 esadecimali)",
fresh["player_id"].length() == 32 and Profile.is_valid_player_id(fresh["player_id"]),
"id=%s" % str(fresh["player_id"]))
# 2) l'id resta lo stesso a ogni lettura (i punteggi vecchi restano collegati)
var again := Profile.load_profile(TEST_PATH)
check("l'id del giocatore e' stabile tra una lettura e l'altra",
again["player_id"] == fresh["player_id"], "%s vs %s" % [str(again["player_id"]), str(fresh["player_id"])])
# 3) nome scelto e punteggio migliore salvati e riletti
var saved := fresh.duplicate()
saved["name"] = "Mat"
saved["best"] = 300
Profile.save_profile(saved, TEST_PATH)
var reloaded := Profile.load_profile(TEST_PATH)
check("nome e miglior punteggio sopravvivono alla riscrittura",
reloaded["name"] == "Mat" and int(reloaded["best"]) == 300,
"nome=%s best=%s" % [str(reloaded["name"]), str(reloaded["best"])])
check("un nome scelto blocca il nome (non si richiede piu')",
reloaded["locked"] == true, "bloccato=%s" % str(reloaded["locked"]))
# 4) il nome scelto e' persistito anche nel file, non solo in memoria
var config := ConfigFile.new()
var wrote := config.load(TEST_PATH) == OK
check("il blocco del nome e' scritto nel file del profilo",
wrote and bool(config.get_value("player", "locked", false)),
"letto=%s" % str(wrote))
# 5) profili vecchi (senza il campo locked) con nome personalizzato risultano bloccati
check("profilo vecchio con nome personalizzato: nome considerato scelto",
Profile.is_name_locked({"name": "Vecchio", "player_id": "x"}) == true)
check("profilo vecchio con nome predefinito: nome non considerato scelto",
Profile.is_name_locked({"name": Profile.DEFAULT_NAME, "player_id": "x"}) == false)
# 6) pulizia del nome secondo le regole del server
check("il nome viene ripulito da apici, emoji e simboli",
Profile.sanitize_name(" Ma't 😀 <b> ") == "Mat b", Profile.sanitize_name(" Ma't 😀 <b> "))
check("il nome vuoto diventa quello predefinito",
Profile.sanitize_name(" ") == Profile.DEFAULT_NAME, Profile.sanitize_name(" "))
check("il nome e' limitato a 16 caratteri",
Profile.sanitize_name("abcdefghijklmnopqrstuvwxyz").length() == Profile.NAME_MAX,
Profile.sanitize_name("abcdefghijklmnopqrstuvwxyz"))
check("le lettere accentate sono conservate",
Profile.sanitize_name("Città àèìòù") == "Città àèìòù", Profile.sanitize_name("Città àèìòù"))
# 7) uuid di partita: forma valida e sempre diverso (idempotenza lato server)
var uuid_a := Profile.new_submission_id()
var uuid_b := Profile.new_submission_id()
var parts := uuid_a.split("-")
check("l'identificativo di partita e' un uuid v4",
parts.size() == 5 and uuid_a.length() == 36 and uuid_a[14] == "4"
and "89ab".contains(uuid_a[19]) and uuid_a != uuid_b, uuid_a)
# 8) testo per JavaScript: apici e barre non rompono il codice
check("il testo per localStorage e' messo in sicurezza",
Profile.js_escape("a'b\\c\nd") == "a\\'b\\\\c d", Profile.js_escape("a'b\\c\nd"))
# 9) id del giocatore: le stesse regole del server
check("l'id del giocatore viene validato come il server",
Profile.is_valid_player_id("abcdef0123456789")
and Profile.is_valid_player_id("player-offline-0001")
and not Profile.is_valid_player_id("corto")
and not Profile.is_valid_player_id("con spazio qui")
and not Profile.is_valid_player_id("apostrofo'qui"))
# 10) valori letti dal browser: quelli assenti NON devono diventare la stringa "null"
var from_browser := {"name": Profile.DEFAULT_NAME, "player_id": "abcdef0123456789",
"best": 0, "locked": false}
Profile.apply_web_json(from_browser, '{"n":null,"i":null,"b":null,"l":null}')
check("valori assenti nel browser non sporcano il profilo (niente \"null\")",
from_browser["name"] == Profile.DEFAULT_NAME and from_browser["locked"] == false
and from_browser["player_id"] == "abcdef0123456789",
"nome=%s bloccato=%s" % [str(from_browser["name"]), str(from_browser["locked"])])
Profile.apply_web_json(from_browser, '{"n":"Mat","i":"024a956d532357c426ceb08ff86124e1","b":"800","l":"1"}')
check("i valori del browser vengono applicati al profilo",
from_browser["name"] == "Mat" and int(from_browser["best"]) == 800 and from_browser["locked"] == true
and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1",
"nome=%s best=%s" % [str(from_browser["name"]), str(from_browser["best"])])
Profile.apply_web_json(from_browser, '{"n":"null","i":"corto","b":"molto","l":"1"}')
check("valori non validi dal browser: nome riparato, id e punteggio intatti",
from_browser["name"] == Profile.DEFAULT_NAME and from_browser["locked"] == false
and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1"
and int(from_browser["best"]) == 800,
"nome=%s id=%s best=%s bloccato=%s" % [str(from_browser["name"]),
str(from_browser["player_id"]), str(from_browser["best"]), str(from_browser["locked"])])
check("testo di un valore nullo: stringa vuota, non \"null\"",
Profile.text_of(null) == "" and Profile.text_of("x") == "x", Profile.text_of(null))
# 11) riparazione automatica: un profilo sporcato con la stringa "null"
# (bug di una versione precedente) torna al nome predefinito e si sblocca.
var junk_path := "user://test_profile_junk.cfg"
var junk := ConfigFile.new()
junk.set_value("player", "name", "null")
junk.set_value("player", "player_id", "abcdef0123456789")
junk.set_value("player", "best", 500)
junk.set_value("player", "locked", true)
junk.save(junk_path)
var repaired := Profile.load_profile(junk_path)
check("un profilo con nome \"null\" viene riparato (nome predefinito, sbloccato)",
repaired["name"] == Profile.DEFAULT_NAME and repaired["locked"] == false
and int(repaired["best"]) == 500,
"nome=%s bloccato=%s best=%s" % [str(repaired["name"]), str(repaired["locked"]),
str(repaired["best"])])
var junk_after := ConfigFile.new()
junk_after.load(junk_path)
check("la riparazione viene riscritta nel file",
str(junk_after.get_value("player", "name", "")) == Profile.DEFAULT_NAME
and bool(junk_after.get_value("player", "locked", true)) == false,
"%s / %s" % [str(junk_after.get_value("player", "name", "")),
str(junk_after.get_value("player", "locked", true))])
DirAccess.remove_absolute(ProjectSettings.globalize_path(junk_path))
DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH))
print("%d controlli superati, %d falliti" % [checks - failures, failures])
quit(1 if failures > 0 else 0)