Files
zombie-fps/tools/test_player_profile.gd
enne2 cb14fd5400 Arcade/kiosk name flow, free cursor in menu and portable Linux export
Every game over unlocks the profile and frees the name, so the menu always asks
for a new player name: the field is empty at every boot, restart or game over and
the game does not start without a name (no anonymous scores). The player identity
is derived from the entered name (deterministic FNV-1a, 16 hex chars valid for the
public API) and the profile keeps the best score per name. Backend untouched.

Mouse: player._ready() captured the pointer before the menu existed, so the start
screen was not clickable. The cursor now always starts visible; the capture happens
only in player.begin_play(), called by start_game(). New `playing` flag gates Esc
and the Web click capture to the actual match.

Linux export preset: single-file x86_64 binary with the PCK embedded, no
libstdc++ dependency, official 4.7.1 template (glibc >= 2.28). Notes and the
export_presets.cfg `#`-is-not-a-comment trap documented in docs/KIOSK-ARCADE.md.

Tests: profile 30/30, start screen 15/15 (cursor free at menu, captured only in
game), boss 50/50, rebalance 14/14, touch 14 PASS, API 42/42, all other suites pass.
2026-10-24 12:50:03 +02:00

186 lines
10 KiB
GDScript

extends SceneTree
## Verifica le regole del profilo locale in modalita' arcade/kiosk (nome mai dato per
## scontato, id derivato dal nome, miglior punteggio per 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: nessun nome e nessun id dati per scontato
var fresh := Profile.load_profile(TEST_PATH)
check("profilo nuovo: campo del nome libero (nessun nome presunto)",
fresh["name"] == "" and fresh["locked"] == false,
"nome=%s bloccato=%s" % [str(fresh["name"]), str(fresh["locked"])])
check("profilo nuovo: nessun id assegnato prima del nome inserito",
str(fresh["player_id"]) == "", "id=%s" % str(fresh["player_id"]))
# 2) identita' derivata dal nome della partita: deterministica e valida per il server
var id_a := Profile.player_id_for_name("Mat")
var id_b := Profile.player_id_for_name("Mat")
check("l'id derivato dal nome e' stabile e accettato dal server",
id_a == id_b and id_a.length() == 16 and Profile.is_valid_player_id(id_a),
"id=%s vs %s" % [id_a, id_b])
check("nomi diversi = identita' diverse",
Profile.player_id_for_name("Lu") != id_a, "%s vs %s" % [str(Profile.player_id_for_name("Lu")), id_a])
check("nome vuoto: id di ripiego generato e valido",
Profile.is_valid_player_id(Profile.player_id_for_name("")),
"id=%s" % Profile.player_id_for_name(""))
# 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("regola arcade: il nome scelto NON blocca, viene richiesto di nuovo",
reloaded["locked"] == false, "bloccato=%s" % str(reloaded["locked"]))
check("il miglior punteggio e' registrato per nome (record per persona)",
Profile.best_for_name(reloaded, "Mat") == 300 and Profile.best_for_name(reloaded, "Lu") == 0,
"mat=%s lu=%s" % [str(Profile.best_for_name(reloaded, "Mat")), str(Profile.best_for_name(reloaded, "Lu"))])
check("game over: profilo sbloccato e nome liberato per la partita nuova",
Profile.unlock_name(reloaded)["locked"] == false and str(reloaded["name"]) == "",
"nome=%s bloccato=%s" % [str(reloaded["name"]), str(reloaded["locked"])])
# 4) nome, record per nome e sblocco sono scritti nel file, non solo in memoria
var config := ConfigFile.new()
var wrote := config.load(TEST_PATH) == OK
var stored_bests: Variant = config.get_value("player", "bests", {})
check("il file conserva il record del nome e il nome non bloccato",
wrote and bool(config.get_value("player", "locked", true)) == false
and stored_bests is Dictionary and int(stored_bests.get("Mat", 0)) == 300,
"letto=%s bests=%s" % [str(wrote), str(stored_bests)])
# 5) i profili delle versioni precedenti arrivano "bloccati": la regola arcade li ignora
check("profilo vecchio con nome personalizzato: comunque non bloccato",
Profile.is_name_locked({"name": "Vecchio", "player_id": "x", "locked": true}) == false)
check("profilo nuovo: il nome non e' bloccato",
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 resta vuoto per il campo, predefinito solo lato server",
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, il nome resta libero (regola arcade)",
from_browser["name"] == "Mat" and int(from_browser["best"]) == 800 and from_browser["locked"] == false
and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1",
"nome=%s best=%s bloccato=%s" % [str(from_browser["name"]), str(from_browser["best"]),
str(from_browser["locked"])])
Profile.apply_web_json(from_browser, '{"n":"null","i":"corto","b":"molto","l":"1"}')
check("valori non validi dal browser: campo del nome liberato, id e punteggio intatti",
from_browser["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 (campo libero, sbloccato)",
repaired["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", "x")) == ""
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))
# 12) Native rename migration: temporary files only, old source is read-only.
var legacy_path := "user://test_legacy_profile.cfg"
var migrated_path := "user://test_migrated_profile.cfg"
DirAccess.remove_absolute(ProjectSettings.globalize_path(migrated_path))
Profile.save_profile({"name": "Legacy", "player_id": "abcdef0123456789",
"best": 850, "locked": true}, legacy_path)
var old_hash := FileAccess.get_sha256(legacy_path)
var migrated := Profile.load_profile(migrated_path, legacy_path)
check("rinomina: nome, id e record migrati senza perdite (nome libero)",
migrated.name == "Legacy" and migrated.player_id == "abcdef0123456789"
and int(migrated.best) == 850 and migrated.locked == false)
check("rinomina: il vecchio file rimane byte-identico",
FileAccess.get_sha256(legacy_path) == old_hash)
check("rinomina: il profilo viene scritto nella nuova posizione",
FileAccess.file_exists(migrated_path))
migrated.name = "Attuale"
migrated.best = 950
Profile.save_profile(migrated, migrated_path)
var preferred := Profile.load_profile(migrated_path, legacy_path)
check("rinomina: un profilo nuovo esistente prevale sul vecchio",
preferred.name == "Attuale" and preferred.best == 950)
check("rinomina: percorso legacy risolto accanto al nuovo user data",
Profile.legacy_profile_path().ends_with("/Zombie Room FPS/zombie_fps_profile.cfg"))
DirAccess.remove_absolute(ProjectSettings.globalize_path(legacy_path))
DirAccess.remove_absolute(ProjectSettings.globalize_path(migrated_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)