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.
This commit is contained in:
enne2
2026-10-24 12:50:03 +02:00
parent 21a1d5bf79
commit cb14fd5400
9 changed files with 397 additions and 106 deletions
+49 -33
View File
@@ -1,6 +1,7 @@
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.
## 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"
@@ -22,19 +23,25 @@ func check(label: String, condition: bool, detail: String = "") -> void:
func run_test() -> void:
DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH))
# 1) file assente: valori predefiniti, id generato, nome non bloccato
# 1) file assente: nessun nome e nessun id dati per scontato
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,
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: 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"]))
check("profilo nuovo: nessun id assegnato prima del nome inserito",
str(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"])])
# 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()
@@ -45,26 +52,34 @@ func run_test() -> void:
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"]))
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) il nome scelto e' persistito anche nel file, non solo in memoria
# 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
check("il blocco del nome e' scritto nel file del profilo",
wrote and bool(config.get_value("player", "locked", false)),
"letto=%s" % str(wrote))
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) 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",
# 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 diventa quello predefinito",
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,
@@ -101,13 +116,14 @@ func run_test() -> void:
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
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" % [str(from_browser["name"]), str(from_browser["best"])])
"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: nome riparato, id e punteggio intatti",
from_browser["name"] == Profile.DEFAULT_NAME and from_browser["locked"] == false
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"]),
@@ -125,15 +141,15 @@ func run_test() -> void:
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
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", "")) == Profile.DEFAULT_NAME
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))])
@@ -147,9 +163,9 @@ func run_test() -> void:
"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, record e blocco migrati senza perdite",
check("rinomina: nome, id e record migrati senza perdite (nome libero)",
migrated.name == "Legacy" and migrated.player_id == "abcdef0123456789"
and migrated.best == 850 and migrated.locked)
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",
+42 -14
View File
@@ -1,8 +1,9 @@
extends SceneTree
## Verifica le regole della schermata iniziale:
## Verifica le regole della schermata iniziale in modalita' arcade/kiosk:
## - la partita parte SOLO dal pulsante GIOCA: click o tocco sullo sfondo, oppure
## Invio/"Vai" nel campo del nome, non devono avviare nulla;
## - il nome scelto una volta non viene piu' richiesto.
## - il campo del nome c'e' sempre, arriva vuoto e senza nome la partita non parte;
## - al ritorno al menu dopo il game over il campo viene liberato per il nuovo giocatore.
## Avviare con: --headless --path . --script res://tools/test_start_screen.gd
## (con display, per gli eventi di puntamento: --rendering-method gl_compatibility)
const Profile = preload("res://scripts/player_profile.gd")
@@ -28,7 +29,7 @@ func check(label: String, condition: bool, detail: String = "") -> void:
func run_test() -> void:
await test_world_menu()
await test_locked_name()
await test_name_always_asked()
await test_unlocked_name()
print("%d controlli superati, %d falliti" % [checks - failures, failures])
quit(1 if failures > 0 else 0)
@@ -54,6 +55,20 @@ func test_world_menu() -> void:
gioca != null and not gioca.get_global_rect().has_point(outside),
"pulsante=%s" % str(gioca))
## Bug risoluto: al menu il cursore deve essere visibile e libero, la cattura
## avviene solo all'inizio della partita (begin_play()).
check("al menu il giocatore non e' ancora in partita (playing=false)",
world.player.playing == false, "playing=%s" % str(world.player.playing))
check("al menu il cursore e' visibile e libero (schermata cliccabile)",
Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "mode=%s" % str(Input.mouse_mode))
var esc := InputEventKey.new()
esc.physical_keycode = KEY_ESCAPE
esc.pressed = true
Input.parse_input_event(esc)
await process_frame
check("Esc al menu non cattura il cursore (solo durante la partita)",
Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "mode=%s" % str(Input.mouse_mode))
_click(outside)
await process_frame
await process_frame
@@ -81,6 +96,11 @@ func test_world_menu() -> void:
await process_frame
check("il pulsante GIOCA avvia la partita", start_events == 1 and world.start_screen == null,
"avvii=%d schermata=%s" % [start_events, "presente" if world.start_screen != null else "via"])
check("all'inizio della partita il giocatore e' in partita e il cursore viene catturato",
world.player.playing == true and (DisplayServer.get_name() == "headless"
or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED),
"playing=%s mode=%s display=%s" % [str(world.player.playing), str(Input.mouse_mode),
DisplayServer.get_name()])
# Regressione: senza --api= questa e' una build desktop di prova e non deve poter
# inviare punteggi alla classifica pubblica (ci sono finite righe "Test").
check("senza --api= la build di prova non invia punteggi alla classifica pubblica",
@@ -93,7 +113,7 @@ func _on_started(player_name: String) -> void:
emitted = player_name
start_events += 1
## --- nome bloccato e non ------------------------------------------------------
## --- nome sempre richiesto (macchina condivisa) ------------------------------
func menu_with(profile: Dictionary) -> CanvasLayer:
var layer := CanvasLayer.new()
@@ -103,26 +123,34 @@ func menu_with(profile: Dictionary) -> CanvasLayer:
layer.started.connect(_on_started)
return layer
func test_locked_name() -> void:
func test_name_always_asked() -> void:
emitted = ""
start_events = 0
# Un profilo scritto da una versione precedente arriva con il nome gia' scelto e
# bloccato: in modalita' arcade il campo deve esserci comunque, vuoto.
var menu := menu_with({"name": "Mat", "player_id": "abcdef0123456789", "best": 300, "locked": true})
await process_frame
var label_text := ""
for label in menu.find_children("*", "Label", true, false):
if str(label.text).begins_with("giocatore:"):
label_text = str(label.text)
check("con nome scelto il campo non viene richiesto",
menu.get("name_field") == null and menu.get("name_locked") == true,
"campo=%s bloccato=%s" % [str(menu.get("name_field")), str(menu.get("name_locked"))])
check("il nome scelto e' mostrato al giocatore", label_text == "giocatore: Mat", label_text)
var field: LineEdit = menu.get("name_field")
var field_text := "" if field == null else str(field.text)
check("regola arcade: campo del nome presente e vuoto anche con profilo precedente",
field != null and menu.get("name_locked") == false and field_text == "",
"campo=%s testo=%s bloccato=%s" % [str(field), field_text, str(menu.get("name_locked"))])
var gioca: Button = null
for button in menu.find_children("*", "Button", true, false):
if button.text == "GIOCA":
gioca = button
if gioca != null:
gioca.pressed.emit()
check("la partita usa il nome memorizzato", emitted == "Mat", "nome=%s" % emitted)
check("senza nome scritto la partita non parte (nessun punteggio anonimo)",
start_events == 0 and emitted == "", "avvii=%d nome=%s" % [start_events, emitted])
if field != null and gioca != null:
field.text = "Neo"
gioca.pressed.emit()
check("con il nome scritto la partita parte con quel nome", emitted == "Neo", "nome=%s" % emitted)
if field != null:
menu.reset_for_new_player()
check("game over: campo liberato per il giocatore successivo", str(field.text) == "",
"testo=%s" % str(field.text))
menu.queue_free()
await process_frame