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,207 @@
|
||||
extends RefCounted
|
||||
## Profilo locale del giocatore: nome scelto, id persistente e ultimo punteggio.
|
||||
## Su desktop vive in user://, sul Web si scrive ANCHE in window.localStorage, perche'
|
||||
## e' la memoria del browser che sopravvive davvero alla ricarica della pagina (la
|
||||
## IndexedDB di Godot puo' non essere sincronizzata). Chiavi: zombie_fps_*.
|
||||
## Il nome, una volta scelto, resta bloccato: non viene piu' richiesto.
|
||||
const PATH := "user://zombie_fps_profile.cfg"
|
||||
const DEFAULT_NAME := "Giocatore"
|
||||
const NAME_MAX := 16
|
||||
const KEY_NAME := "zombie_fps_name"
|
||||
const KEY_PLAYER_ID := "zombie_fps_player_id"
|
||||
const KEY_BEST := "zombie_fps_best"
|
||||
const KEY_LOCKED := "zombie_fps_locked"
|
||||
|
||||
## Legge il profilo (o i valori predefiniti) e lo riscrive, cosi' il primo avvio
|
||||
## lascia sempre un id stabile e le chiavi del browser allineate.
|
||||
static func load_profile(path := PATH) -> Dictionary:
|
||||
var data := {"name": DEFAULT_NAME, "player_id": "", "best": 0, "locked": false}
|
||||
var config := ConfigFile.new()
|
||||
if config.load(path) == OK:
|
||||
var raw_name := text_of(config.get_value("player", "name", DEFAULT_NAME))
|
||||
# Una versione precedente poteva salvare la stringa "null": in quel caso il
|
||||
# nome va considerato non scelto e il blocco rimosso (riparazione automatica).
|
||||
data["name"] = sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME
|
||||
data["player_id"] = str(config.get_value("player", "player_id", ""))
|
||||
data["best"] = int(config.get_value("player", "best", 0))
|
||||
data["locked"] = bool(config.get_value("player", "locked", false)) and is_stored_name_usable(raw_name)
|
||||
if path == PATH:
|
||||
_web_merge(data)
|
||||
if data["player_id"] == "":
|
||||
data["player_id"] = new_player_id()
|
||||
# Vale anche per i profili scritti prima di questa regola: un nome diverso da
|
||||
# quello predefinito significa che il giocatore l'ha scelto di proposito.
|
||||
data["locked"] = is_name_locked(data)
|
||||
save_profile(data, path)
|
||||
return data
|
||||
|
||||
static func save_profile(data: Dictionary, path := PATH) -> void:
|
||||
var raw_name := text_of(data.get("name"))
|
||||
var name := sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME
|
||||
var locked := is_name_locked(data)
|
||||
data["name"] = name
|
||||
data["locked"] = locked
|
||||
var config := ConfigFile.new()
|
||||
config.set_value("player", "name", name)
|
||||
config.set_value("player", "player_id", str(data.get("player_id", "")))
|
||||
config.set_value("player", "best", int(data.get("best", 0)))
|
||||
config.set_value("player", "locked", locked)
|
||||
config.save(path)
|
||||
if path == PATH:
|
||||
_web_set(KEY_NAME, name)
|
||||
_web_set(KEY_PLAYER_ID, str(data.get("player_id", "")))
|
||||
_web_set(KEY_BEST, str(int(data.get("best", 0))))
|
||||
_web_set(KEY_LOCKED, "1" if locked else "0")
|
||||
|
||||
## Vero se il testo e' un nome memorizzato utilizzabile. Le versioni precedenti
|
||||
## potevano salvare la stringa "null" (`str(null)` in GDScript): va scartata.
|
||||
static func is_stored_name_usable(value: Variant) -> bool:
|
||||
var text := text_of(value).strip_edges()
|
||||
return text != "" and text != "null" and text != "undefined"
|
||||
|
||||
## Il nome e' bloccato quando il giocatore l'ha scelto: non si richiede piu'.
|
||||
static func is_name_locked(data: Dictionary) -> bool:
|
||||
if bool(data.get("locked", false)):
|
||||
return true
|
||||
return sanitize_name(str(data.get("name", DEFAULT_NAME))) != DEFAULT_NAME
|
||||
|
||||
## Id persistente del giocatore: 32 caratteri esadecimali casuali.
|
||||
static func new_player_id() -> String:
|
||||
var bytes := random_bytes(16)
|
||||
var text := ""
|
||||
for byte in bytes:
|
||||
text += "%02x" % byte
|
||||
return text
|
||||
|
||||
## Uuid v4 per identificare UNA partita: il server lo usa per non duplicare i retry.
|
||||
static func new_submission_id() -> String:
|
||||
var bytes := random_bytes(16)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40 # versione 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80 # variante
|
||||
var text := ""
|
||||
for byte in bytes:
|
||||
text += "%02x" % byte
|
||||
return "%s-%s-%s-%s-%s" % [text.substr(0, 8), text.substr(8, 4), text.substr(12, 4),
|
||||
text.substr(16, 4), text.substr(20, 12)]
|
||||
|
||||
## Byte casuali da Crypto, con ripiego sul generatore della scena se non disponibile.
|
||||
static func random_bytes(count: int) -> PackedByteArray:
|
||||
if ClassDB.class_exists("Crypto"):
|
||||
var crypto := Crypto.new()
|
||||
var bytes := crypto.generate_random_bytes(count)
|
||||
if bytes.size() == count:
|
||||
return bytes
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.randomize()
|
||||
var fallback := PackedByteArray()
|
||||
for i in count:
|
||||
fallback.append(rng.randi_range(0, 255))
|
||||
return fallback
|
||||
|
||||
## Id del giocatore secondo le stesse regole del server (8-64 caratteri sicuri).
|
||||
static func is_valid_player_id(text: String) -> bool:
|
||||
if text.length() < 8 or text.length() > 64:
|
||||
return false
|
||||
for character in text:
|
||||
var allowed := character.is_valid_int() or character == "-" or character == "_"
|
||||
allowed = allowed or (character.to_lower() != character.to_upper())
|
||||
if not allowed:
|
||||
return false
|
||||
return true
|
||||
|
||||
## Pulizia del nome secondo le stesse regole del server: 1-16 caratteri utili,
|
||||
## niente apici/emoji (il server risponderebbe 400 e il punteggio andrebbe perso).
|
||||
static func sanitize_name(raw: String) -> String:
|
||||
var cleaned := ""
|
||||
for character in raw.strip_edges():
|
||||
if cleaned.length() >= NAME_MAX:
|
||||
break
|
||||
if character == "\n" or character == "\t" or character == "\r":
|
||||
character = " "
|
||||
var allowed := character == " " or character == "-" or character == "."
|
||||
allowed = allowed or character.is_valid_int()
|
||||
allowed = allowed or (character.to_lower() != character.to_upper())
|
||||
if allowed:
|
||||
cleaned += character
|
||||
cleaned = cleaned.strip_edges()
|
||||
while cleaned.contains(" "):
|
||||
cleaned = cleaned.replace(" ", " ")
|
||||
if cleaned.strip_edges() == "":
|
||||
cleaned = DEFAULT_NAME
|
||||
return cleaned
|
||||
|
||||
## Testo pronto per un letterale JavaScript fra apici singoli.
|
||||
static func js_escape(text: String) -> String:
|
||||
var out := ""
|
||||
for character in text:
|
||||
if character == "\\":
|
||||
out += "\\\\"
|
||||
elif character == "'":
|
||||
out += "\\'"
|
||||
elif character == "\n" or character == "\r" or character == "\t":
|
||||
out += " "
|
||||
else:
|
||||
out += character
|
||||
return out
|
||||
|
||||
## --- memoria del browser (solo sul Web) -------------------------------------
|
||||
|
||||
static func _web_eval(code: String) -> String:
|
||||
if not OS.has_feature("web"):
|
||||
return ""
|
||||
if not ClassDB.class_exists("JavaScriptBridge"):
|
||||
return ""
|
||||
return str(JavaScriptBridge.eval(code, true))
|
||||
|
||||
static func _web_merge(data: Dictionary) -> void:
|
||||
var raw := _web_eval(
|
||||
"(function(){try{return JSON.stringify({n:window.localStorage.getItem('%s')||''," % KEY_NAME
|
||||
+ "i:window.localStorage.getItem('%s')||''," % KEY_PLAYER_ID
|
||||
+ "b:window.localStorage.getItem('%s')||''," % KEY_BEST
|
||||
+ "l:window.localStorage.getItem('%s')||''});}catch(e){return '';}})()" % KEY_LOCKED)
|
||||
if raw == "" or raw == "<null>":
|
||||
return
|
||||
apply_web_json(data, raw)
|
||||
|
||||
## Applica i valori letti dal browser al profilo. E' una funzione PURA (riceve il
|
||||
## JSON come testo) cosi' si puo' verificare senza browser: attenzione, in GDScript
|
||||
## `str(null)` vale "null", quindi i valori assenti vanno scartati, non convertiti.
|
||||
static func apply_web_json(data: Dictionary, raw: String) -> Dictionary:
|
||||
var parsed: Variant = JSON.parse_string(raw)
|
||||
if not (parsed is Dictionary):
|
||||
return data
|
||||
var stored: Dictionary = parsed
|
||||
var raw_name := text_of(stored.get("n"))
|
||||
if raw_name != "":
|
||||
if is_stored_name_usable(raw_name):
|
||||
data["name"] = sanitize_name(raw_name)
|
||||
else:
|
||||
data["name"] = DEFAULT_NAME
|
||||
data["locked"] = false
|
||||
var player_id := text_of(stored.get("i"))
|
||||
if is_valid_player_id(player_id):
|
||||
data["player_id"] = player_id
|
||||
var best := text_of(stored.get("b"))
|
||||
if best.is_valid_int():
|
||||
data["best"] = int(best)
|
||||
if text_of(stored.get("l")) == "1" and is_stored_name_usable(raw_name):
|
||||
data["locked"] = true
|
||||
return data
|
||||
|
||||
## Testo di un valore che puo' arrivare dal browser o da un file: null e numeri
|
||||
## diventano stringhe sensate (in GDScript `str(null)` produrrebbe "null").
|
||||
static func text_of(value: Variant) -> String:
|
||||
if value == null:
|
||||
return ""
|
||||
if value is String:
|
||||
return str(value).strip_edges()
|
||||
return str(value)
|
||||
|
||||
static func _web_set(key: String, value: String) -> void:
|
||||
_web_eval("(function(){try{window.localStorage.setItem('%s','%s');return '1';}catch(e){return '0';}})()"
|
||||
% [js_escape(key), js_escape(value)])
|
||||
|
||||
## Cancella le chiavi del browser: serve ai test, non al gioco.
|
||||
static func web_clear() -> void:
|
||||
for key in [KEY_NAME, KEY_PLAYER_ID, KEY_BEST, KEY_LOCKED]:
|
||||
_web_eval("(function(){try{window.localStorage.removeItem('%s');return '1';}catch(e){return '0';}})()" % key)
|
||||
Reference in New Issue
Block a user