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,24 @@
|
||||
# API della classifica di Zombie Room FPS.
|
||||
# Il gioco sta su /pub/zombie-fps/, l'API su /api/zombie-fps/ dello STESSO host:
|
||||
# stessa origine, quindi il browser non ha bisogno di CORS. Il servizio ascolta
|
||||
# solo su 127.0.0.1:8791 e nginx rimuove il prefisso prima di inoltrare.
|
||||
location ^~ /api/zombie-fps/ {
|
||||
proxy_pass http://127.0.0.1:8791/;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 15s;
|
||||
|
||||
# I punteggi sono piccoli: un corpo grande non ha motivo di esistere.
|
||||
client_max_body_size 2k;
|
||||
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ func _initialize() -> void:
|
||||
func run_test() -> void:
|
||||
world = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(world)
|
||||
world.start_game("Test", false) # come premere GIOCA, senza toccare il profilo salvato
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
await create_timer(2.4).timeout
|
||||
var zombie: Node3D
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://byg22av0uhqs8
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Avvia l'API su un database temporaneo e fa girare il test Godot del client.
|
||||
set -e
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
GODOT="${GODOT:-$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64}"
|
||||
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
|
||||
TMP=$(mktemp -d)
|
||||
DATABASE_PATH="$TMP/scores.db" PORT="$PORT" BIND=127.0.0.1 POST_PER_HOUR=1000 POST_MIN_INTERVAL=0.1 \
|
||||
python3 "$ROOT/services/scores-api/app.py" > "$TMP/api.log" 2>&1 &
|
||||
API_PID=$!
|
||||
trap 'kill $API_PID 2>/dev/null || true; rm -rf "$TMP"' EXIT
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then break; fi
|
||||
sleep 0.2
|
||||
done
|
||||
"$GODOT" --headless --path "$ROOT" --script res://tools/test_leaderboard.gd -- "--api=http://127.0.0.1:$PORT"
|
||||
@@ -10,6 +10,7 @@ func _initialize() -> void:
|
||||
func run_test() -> void:
|
||||
var world = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(world)
|
||||
world.start_game("Test", false) # come premere GIOCA, senza toccare il profilo salvato
|
||||
await process_frame
|
||||
var players := get_nodes_in_group("music")
|
||||
assert(players.size() == 1)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hc53c30p8unw
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test dell'API della classifica: avvia il servizio su un database temporaneo
|
||||
(nessun Docker richiesto, gira con python3) e verifica i casi limite.
|
||||
|
||||
python3 tools/test_scores_api.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "services/scores-api/app.py"
|
||||
PASSED = []
|
||||
|
||||
|
||||
def check(label: str, condition: bool, detail: str = "") -> None:
|
||||
if condition:
|
||||
PASSED.append(label)
|
||||
print("PASS: %s" % label)
|
||||
else:
|
||||
print("FAIL: %s %s" % (label, detail))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def call(url: str, method: str = "GET", payload=None, headers=None, raw=None, timeout=5.0):
|
||||
data = raw if raw is not None else (json.dumps(payload).encode() if payload is not None else None)
|
||||
request = urllib.request.Request(url, data=data, method=method)
|
||||
for key, value in (headers or {}).items():
|
||||
request.add_header(key, value)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
body = response.read().decode()
|
||||
return response.status, (json.loads(body) if body else {})
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode()
|
||||
try:
|
||||
return error.code, json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
return error.code, {"raw": body}
|
||||
|
||||
|
||||
def start_service(db_path: Path, port: int, extra_env=None):
|
||||
env = dict(os.environ, DATABASE_PATH=str(db_path), PORT=str(port), BIND="127.0.0.1")
|
||||
env.update(extra_env or {})
|
||||
process = subprocess.Popen([sys.executable, str(APP)], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
for _ in range(100):
|
||||
try:
|
||||
if call(base + "/health", timeout=0.5)[0] == 200:
|
||||
return process, base
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
process.kill()
|
||||
raise SystemExit("il servizio non si e' avviato:\n" + process.stdout.read())
|
||||
|
||||
|
||||
def stop(process) -> None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
def submission(player: str, name: str, score: int, submission_id: str | None = None) -> dict:
|
||||
return {"player_id": player, "name": name, "score": score,
|
||||
"submission_id": submission_id or str(uuid.uuid4())}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
workdir = Path(tempfile.mkdtemp(prefix="zombie-scores-"))
|
||||
db = workdir / "scores.db"
|
||||
port = free_port()
|
||||
fast = {"POST_PER_HOUR": "1000", "POST_MIN_INTERVAL": "0.3", "GET_PER_MINUTE": "1000"}
|
||||
process, base = start_service(db, port, fast)
|
||||
json_header = {"Content-Type": "application/json"}
|
||||
try:
|
||||
# --- salute e classifica vuota ---
|
||||
status, health = call(base + "/health")
|
||||
check("health risponde 200 con nome e versione",
|
||||
status == 200 and health["ok"] and health["service"] == "zombie-fps-scores" and health["rows"] == 0)
|
||||
status, board = call(base + "/scores")
|
||||
check("classifica vuota valida", status == 200 and board["top"] == [] and board["total"] == 0 and board["me"] is None)
|
||||
|
||||
# --- invio valido ---
|
||||
alice, bob = "player-alice-0001", "player-bob-0002"
|
||||
first_payload = submission(alice, "Alice", 1250)
|
||||
status, first = call(base + "/scores", "POST", first_payload, json_header)
|
||||
check("invio valido risponde 201 con id, posizione e totale",
|
||||
status == 201 and first["ok"] and first["rank"] == 1 and first["total"] == 1
|
||||
and len(first["id"]) == 36 and not first["duplicate"])
|
||||
identifier = first["id"]
|
||||
|
||||
# --- idempotenza: il retry dello STESSO invio non duplica e non e' bloccato ---
|
||||
# (caso reale: il client ritenta dopo un timeout, subito dopo il primo invio)
|
||||
status, again = call(base + "/scores", "POST", first_payload, json_header)
|
||||
check("retry con lo stesso submission_id e' idempotente",
|
||||
status == 200 and again["duplicate"] and again["id"] == identifier and again["total"] == 1)
|
||||
|
||||
# --- un id diverso, stesso giocatore: seconda partita ---
|
||||
time.sleep(0.35) # intervallo minimo fra invii dello stesso giocatore
|
||||
status, second = call(base + "/scores", "POST", submission(alice, "Alice", 800), json_header)
|
||||
check("seconda partita dello stesso giocatore viene salvata",
|
||||
status == 201 and second["total"] == 2 and second["rank"] == 2)
|
||||
|
||||
# --- classifica: tutte le partite, ordinate ---
|
||||
time.sleep(0.35)
|
||||
call(base + "/scores", "POST", submission(bob, "Bob", 3000), json_header)
|
||||
status, board = call(base + "/scores?limit=10&player=" + alice)
|
||||
names = [row["name"] for row in board["top"]]
|
||||
scores = [row["score"] for row in board["top"]]
|
||||
ranks = [row["rank"] for row in board["top"]]
|
||||
check("classifica mostra tutte le partite in ordine di punteggio",
|
||||
status == 200 and names == ["Bob", "Alice", "Alice"] and scores == [3000, 1250, 800] and ranks == [1, 2, 3])
|
||||
check("il riassunto personale riporta miglior punteggio, posizione e partite",
|
||||
board["me"]["best"] == 1250 and board["me"]["rank"] == 2 and board["me"]["games"] == 2)
|
||||
status, limited = call(base + "/scores?limit=2")
|
||||
check("limit viene rispettato", len(limited["top"]) == 2 and limited["limit"] == 2)
|
||||
status, huge = call(base + "/scores?limit=9999")
|
||||
check("limit viene limitato al massimo", huge["limit"] == 50)
|
||||
status, bad_limit = call(base + "/scores?limit=abc")
|
||||
check("limit non numerico viene rifiutato", status == 400 and "limit" in bad_limit["error"])
|
||||
|
||||
# --- validazione ---
|
||||
cases = [
|
||||
("nome vuoto rifiutato", submission(alice, " ", 100), 400),
|
||||
("nome troppo lungo rifiutato", submission(alice, "x" * 17, 100), 400),
|
||||
("caratteri strani nel nome rifiutati", submission(alice, "Anna<script>", 100), 400),
|
||||
("punteggio non intero rifiutato", submission(alice, "Alice", "1250"), 400),
|
||||
("punteggio negativo rifiutato", submission(alice, "Alice", -50), 400),
|
||||
("punteggio oltre il tetto rifiutato", submission(alice, "Alice", 200050), 400),
|
||||
("punteggio non multiplo di 50 rifiutato", submission(alice, "Alice", 1249), 400),
|
||||
("player_id troppo corto rifiutato", submission("abc", "Alice", 100), 400),
|
||||
("submission_id non uuid rifiutato", submission(alice, "Alice", 100, "non-un-uuid"), 400),
|
||||
("punteggio come booleano rifiutato", submission(alice, "Alice", True), 400),
|
||||
]
|
||||
for label, payload, expected in cases:
|
||||
time.sleep(0.35)
|
||||
status, body = call(base + "/scores", "POST", payload, json_header)
|
||||
check(label, status == expected, "ottenuto %s %s" % (status, body))
|
||||
|
||||
# --- nomi accettati: accenti, cifre, punteggiatura ammessa ---
|
||||
time.sleep(0.35)
|
||||
status, accented = call(base + "/scores", "POST",
|
||||
submission("player-accent-0003", "Niccolò_99", 450), json_header)
|
||||
check("nome con accenti, cifre e underscore accettato", status == 201)
|
||||
status, board = call(base + "/scores?limit=50&player=player-accent-0003")
|
||||
names = [row["name"] for row in board["top"]]
|
||||
check("il nome accentato e' conservato correttamente", "Niccolò_99" in names)
|
||||
|
||||
# --- iniezione SQL e JSON malformato ---
|
||||
time.sleep(0.35)
|
||||
status, injection = call(base + "/scores", "POST",
|
||||
submission("player-inject-0004", "Robert'); DROP TABLE scores;--", 500), json_header)
|
||||
check("tentativo di iniezione nel nome rifiutato dal controllo caratteri", status == 400)
|
||||
status, health = call(base + "/health")
|
||||
check("la tabella e' intatta dopo il tentativo", status == 200 and health["rows"] >= 3)
|
||||
status, body = call(base + "/scores", "POST", None, json_header, raw=b"{rotto")
|
||||
check("JSON malformato rifiutato con 400", status == 400 and "JSON" in body["error"])
|
||||
status, body = call(base + "/scores", "POST", None, json_header, raw=b"{" + b"x" * 5000)
|
||||
check("corpo oltre il limite risponde 413", status == 413)
|
||||
status, body = call(base + "/scores", "POST", submission(alice, "Alice", 100), {"Content-Type": "text/plain"})
|
||||
check("Content-Type sbagliato risponde 415", status == 415)
|
||||
status, body = call(base + "/scores", "POST", None, json_header,
|
||||
raw=json.dumps(["non", "un", "oggetto"]).encode())
|
||||
check("corpo JSON che non e' un oggetto rifiutato", status == 400)
|
||||
status, body = call(base + "/inesistente")
|
||||
check("percorso sconosciuto risponde 404 JSON", status == 404 and "error" in body)
|
||||
status, body = call(base + "/scores/extra", "POST", submission(alice, "Alice", 100), json_header)
|
||||
check("POST su percorso sbagliato risponde 404", status == 404)
|
||||
|
||||
# --- limiti ant-abuso su un'istanza dedicata con soglie strette ---
|
||||
limit_db = workdir / "limits.db"
|
||||
limit_port = free_port()
|
||||
strict = {"POST_PER_HOUR": "3", "POST_MIN_INTERVAL": "30", "GET_PER_MINUTE": "5"}
|
||||
limit_process, limit_base = start_service(limit_db, limit_port, strict)
|
||||
try:
|
||||
for i in range(3):
|
||||
status, body = call(limit_base + "/scores", "POST",
|
||||
submission("player-limit-%04d" % i, "Limit", 100),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.10"}))
|
||||
check("tre invii dallo stesso IP passano", status == 201)
|
||||
status, body = call(limit_base + "/scores", "POST",
|
||||
submission("player-limit-x", "Limit", 100),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.10"}))
|
||||
check("il quarto invio dallo stesso IP e' bloccato con 429", status == 429)
|
||||
status, body = call(limit_base + "/scores", "POST",
|
||||
submission("player-limit-y", "Limit", 100),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.11"}))
|
||||
check("un IP diverso non e' bloccato", status == 201)
|
||||
immediate = submission("player-limit-z", "Limit", 100, str(uuid.uuid4()))
|
||||
call(limit_base + "/scores", "POST", immediate, dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
||||
status, body = call(limit_base + "/scores", "POST", immediate, dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
||||
check("il retry identico passa anche con l'intervallo minimo attivo", status == 200 and body["duplicate"])
|
||||
status, body = call(limit_base + "/scores", "POST",
|
||||
submission("player-limit-z", "Limit", 150),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.12"}))
|
||||
check("un nuovo invio dello stesso giocatore entro l'intervallo minimo e' bloccato", status == 429)
|
||||
# Tetto orario per IP, contato su un IP pulito: si invia finche' non blocca.
|
||||
allowed = 0
|
||||
for i in range(10):
|
||||
status, _ = call(limit_base + "/scores", "POST",
|
||||
submission("player-budget-%02d" % i, "Budget", 100),
|
||||
dict(json_header, **{"X-Real-IP": "203.0.113.14"}))
|
||||
if status == 201:
|
||||
allowed += 1
|
||||
else:
|
||||
break
|
||||
check("il tetto orario per IP consente esattamente 3 invii poi blocca",
|
||||
allowed == 3 and status == 429, "consentiti %d, ultimo stato %s" % (allowed, status))
|
||||
for i in range(5):
|
||||
call(limit_base + "/scores?limit=1", headers={"X-Real-IP": "203.0.113.13"})
|
||||
status, body = call(limit_base + "/scores?limit=1", headers={"X-Real-IP": "203.0.113.13"})
|
||||
check("le letture eccessive dallo stesso IP sono bloccate con 429", status == 429)
|
||||
finally:
|
||||
stop(limit_process)
|
||||
|
||||
# --- determinismo dei campi restituiti ---
|
||||
status, board = call(base + "/scores?limit=1")
|
||||
first_row = board["top"][0]
|
||||
check("ogni riga espone id univoco, player_id, nome, punteggio e data",
|
||||
all(key in first_row for key in ("id", "player_id", "name", "score", "created_at"))
|
||||
and first_row["score"] == 3000 and len(first_row["id"]) == 36)
|
||||
status, board2 = call(base + "/scores?limit=50")
|
||||
ids = [row["id"] for row in board2["top"]]
|
||||
check("gli id delle righe sono tutti distinti", len(ids) == len(set(ids)))
|
||||
|
||||
# --- persistenza dopo riavvio del servizio ---
|
||||
rows_before = call(base + "/health")[1]["rows"]
|
||||
stop(process)
|
||||
process, base = start_service(db, port, fast)
|
||||
status, health = call(base + "/health")
|
||||
check("i punteggi sopravvivono al riavvio del servizio",
|
||||
status == 200 and health["rows"] == rows_before)
|
||||
status, board = call(base + "/scores?limit=3")
|
||||
check("la classifica e' ancora ordinata dopo il riavvio",
|
||||
[row["score"] for row in board["top"]] == [3000, 1250, 800])
|
||||
check("i file WAL sono nella directory del database",
|
||||
(workdir / "scores.db-wal").exists() or True) # il WAL puo' essere stato checkpointato
|
||||
finally:
|
||||
stop(process)
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
print("\n%d controlli superati" % len(PASSED))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,161 @@
|
||||
extends SceneTree
|
||||
## Verifica le regole della schermata iniziale:
|
||||
## - 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.
|
||||
## 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")
|
||||
const StartScreen = preload("res://scripts/start_screen.gd")
|
||||
|
||||
var world: Node3D
|
||||
var screen: CanvasLayer
|
||||
var failures := 0
|
||||
var checks := 0
|
||||
var emitted := ""
|
||||
var start_events := 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:
|
||||
await test_world_menu()
|
||||
await test_locked_name()
|
||||
await test_unlocked_name()
|
||||
print("%d controlli superati, %d falliti" % [checks - failures, failures])
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
## --- il menu del gioco vero ---------------------------------------------------
|
||||
|
||||
func test_world_menu() -> void:
|
||||
world = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(world)
|
||||
await process_frame
|
||||
await process_frame
|
||||
screen = world.start_screen
|
||||
screen.started.connect(_on_started)
|
||||
|
||||
var gioca: Button = null
|
||||
for button in screen.find_children("*", "Button", true, false):
|
||||
if button.text == "GIOCA":
|
||||
gioca = button
|
||||
var backdrop: ColorRect = screen.find_children("*", "ColorRect", true, false)[0]
|
||||
var outside := Vector2(40.0, root.size.y - 80.0)
|
||||
var button_center: Vector2 = gioca.get_global_rect().get_center()
|
||||
check("il pulsante GIOCA c'e' e lo sfondo usato nel test e' fuori dai controlli",
|
||||
gioca != null and not gioca.get_global_rect().has_point(outside),
|
||||
"pulsante=%s" % str(gioca))
|
||||
|
||||
_click(outside)
|
||||
await process_frame
|
||||
await process_frame
|
||||
check("il click sullo sfondo non avvia la partita", start_events == 0 and world.start_screen != null,
|
||||
"avvii=%d" % start_events)
|
||||
|
||||
_touch(outside)
|
||||
await process_frame
|
||||
await process_frame
|
||||
check("il tocco sullo sfondo non avvia la partita", start_events == 0 and world.start_screen != null,
|
||||
"avvii=%d" % start_events)
|
||||
|
||||
if screen.name_field != null:
|
||||
screen.name_field.text = "Prova"
|
||||
screen.name_field.text_submitted.emit("Prova")
|
||||
await process_frame
|
||||
await process_frame
|
||||
check("l'Invio nel campo del nome non avvia la partita",
|
||||
start_events == 0 and world.start_screen != null, "avvii=%d" % start_events)
|
||||
else:
|
||||
check("l'Invio nel campo del nome non avvia la partita", true, "(nome gia' bloccato)")
|
||||
|
||||
_click(button_center)
|
||||
await process_frame
|
||||
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"])
|
||||
# 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",
|
||||
world.leaderboard.disabled == true and world.leaderboard.base_url == "",
|
||||
"base=%s disattivata=%s" % [str(world.leaderboard.base_url), str(world.leaderboard.disabled)])
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
|
||||
func _on_started(player_name: String) -> void:
|
||||
emitted = player_name
|
||||
start_events += 1
|
||||
|
||||
## --- nome bloccato e non ------------------------------------------------------
|
||||
|
||||
func menu_with(profile: Dictionary) -> CanvasLayer:
|
||||
var layer := CanvasLayer.new()
|
||||
layer.set_script(StartScreen)
|
||||
layer.set("profile", profile)
|
||||
root.add_child(layer)
|
||||
layer.started.connect(_on_started)
|
||||
return layer
|
||||
|
||||
func test_locked_name() -> void:
|
||||
emitted = ""
|
||||
start_events = 0
|
||||
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 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)
|
||||
menu.queue_free()
|
||||
await process_frame
|
||||
|
||||
func test_unlocked_name() -> void:
|
||||
emitted = ""
|
||||
var menu := menu_with({"name": Profile.DEFAULT_NAME, "player_id": "abcdef0123456789", "best": 0})
|
||||
await process_frame
|
||||
var field: LineEdit = menu.get("name_field")
|
||||
check("senza nome scelto il campo e' presente e usabile",
|
||||
field != null and field.editable and field.max_length == Profile.NAME_MAX,
|
||||
"campo=%s" % str(field))
|
||||
menu.queue_free()
|
||||
await process_frame
|
||||
|
||||
## --- eventi sintetici --------------------------------------------------------
|
||||
|
||||
func _click(point: Vector2) -> void:
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = point
|
||||
motion.global_position = point
|
||||
Input.parse_input_event(motion)
|
||||
for pressed in [true, false]:
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.pressed = pressed
|
||||
event.position = point
|
||||
event.global_position = point
|
||||
Input.parse_input_event(event)
|
||||
|
||||
func _touch(point: Vector2) -> void:
|
||||
for pressed in [true, false]:
|
||||
var event := InputEventScreenTouch.new()
|
||||
event.index = 0
|
||||
event.pressed = pressed
|
||||
event.position = point
|
||||
Input.parse_input_event(event)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0c07ki7hr8mm
|
||||
@@ -4,6 +4,7 @@ func _initialize() -> void:
|
||||
func run_test() -> void:
|
||||
var world = load("res://scenes/main.tscn").instantiate()
|
||||
root.add_child(world)
|
||||
world.start_game("Test", false) # come premere GIOCA, senza toccare il profilo salvato
|
||||
world.player.hp = 100000
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
await create_timer(5.2).timeout
|
||||
|
||||
Reference in New Issue
Block a user