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
+3
View File
@@ -0,0 +1,3 @@
data/
*.pyc
__pycache__/
+18
View File
@@ -0,0 +1,18 @@
# Immagine minima: nessuna dipendenza pip, nessuna rete in fase di build.
FROM python:3.12-alpine3.20
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATABASE_PATH=/data/scores.db \
PORT=8791
WORKDIR /app
COPY app.py /app/app.py
RUN adduser -D -u 10001 scores && mkdir -p /data && chown scores /data
USER scores
EXPOSE 8791
HEALTHCHECK --interval=15s --timeout=3s --retries=3 --start-period=5s \
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8791/health',timeout=2)"
CMD ["python", "/app/app.py"]
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Zombie Room FPS — leaderboard API.
Solo libreria standard: nessuna dipendenza pip, immagine minima e codice
verificabile. Dietro nginx: il percorso pubblico e' /api/zombie-fps/ e nginx
rimuove quel prefisso prima di inoltrare, quindi qui i percorsi sono /health,
/scores, ...
Endpoint
GET /health stato del servizio
GET /scores?limit=10&player=<id> classifica (tutte le partite) + il tuo riassunto
POST /scores {"player_id","name","score","submission_id"}
Il punteggio arriva dal client ed e' quindi falsificabile: non c'e' anti-cheat.
Le difese sono tetto di plausibilita', rate limit e idempotenza dei retry.
"""
import json
import os
import re
import sqlite3
import threading
import time
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
VERSION = "1.0.0"
SERVICE = "zombie-fps-scores"
DB_PATH = os.environ.get("DATABASE_PATH", "/data/scores.db")
PORT = int(os.environ.get("PORT", "8791"))
BIND = os.environ.get("BIND", "0.0.0.0")
MAX_BODY_BYTES = int(os.environ.get("MAX_BODY_BYTES", "2048"))
NAME_RE = re.compile(r"^[\w \-.]{1,16}$", re.UNICODE) # lettere (anche accentate), cifre, spazio, _ - .
ID_RE = re.compile(r"^[A-Za-z0-9_\-]{8,64}$")
# Il gioco assegna 100/150 per zombie: il punteggio e' sempre multiplo di 50.
SCORE_MIN, SCORE_MAX, SCORE_STEP = 0, 200000, 50
NAME_MAX = 16
TOP_DEFAULT, TOP_MAX = 10, 50
# Limiti ant-abuso, configurabili per poterli testare su un'istanza dedicata.
POST_PER_HOUR = int(os.environ.get("POST_PER_HOUR", "20"))
POST_MIN_INTERVAL = float(os.environ.get("POST_MIN_INTERVAL", "5.0"))
GET_PER_MINUTE = int(os.environ.get("GET_PER_MINUTE", "120"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS scores (
id TEXT PRIMARY KEY,
player_id TEXT NOT NULL,
submission_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_scores_rank ON scores(score DESC, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_scores_player ON scores(player_id, score DESC);
"""
_lock = threading.Lock()
_post_by_ip: dict = {} # ip -> [timestamp, ...]
_last_by_player: dict = {} # player_id -> timestamp
_get_by_ip: dict = {} # ip -> [timestamp, ...]
class ApiError(Exception):
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=5.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 5000")
conn.execute("PRAGMA journal_mode = WAL") # regge lettori e un scrittore insieme
conn.execute("PRAGMA synchronous = FULL") # un invio confermato sopravvive al riavvio
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db() -> None:
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
conn = connect()
try:
conn.executescript(SCHEMA)
conn.commit()
finally:
conn.close()
def rank_of(conn: sqlite3.Connection, score: int, created_at: str) -> int:
row = conn.execute(
"SELECT COUNT(*) AS n FROM scores WHERE score > ? OR (score = ? AND created_at < ?)",
(score, score, created_at)).fetchone()
return int(row["n"]) + 1
def clamp_seconds(window: float, stamps: list) -> list:
cutoff = time.monotonic() - window
return [s for s in stamps if s >= cutoff]
def check_rate(store: dict, key: str, window: float, limit: int) -> None:
"""Solo verifica: non modifica lo stato, cosi' un invio respinto per un altro
motivo non consuma il budget dell'IP."""
stamps = clamp_seconds(window, store.get(key, []))
store[key] = stamps
if len(stamps) >= limit:
raise ApiError(429, "troppi invii, riprova piu' tardi")
def record_hit(store: dict, key: str) -> None:
store.setdefault(key, []).append(time.monotonic())
def validate_submission(payload: dict) -> dict:
if not isinstance(payload, dict):
raise ApiError(400, "corpo JSON non valido")
name = payload.get("name")
player_id = payload.get("player_id")
submission_id = payload.get("submission_id")
score = payload.get("score")
if not isinstance(name, str):
raise ApiError(400, "nome mancante")
name = " ".join(name.split()) # niente newline, tab, spazi doppi
if not NAME_RE.match(name):
raise ApiError(400, "nome non valido: 1-16 caratteri, lettere cifre spazio . _ -")
if not isinstance(player_id, str) or not ID_RE.match(player_id):
raise ApiError(400, "player_id non valido")
if not isinstance(submission_id, str):
raise ApiError(400, "submission_id mancante")
try:
uuid.UUID(submission_id)
except (ValueError, AttributeError):
raise ApiError(400, "submission_id deve essere un uuid")
if isinstance(score, bool) or not isinstance(score, int):
raise ApiError(400, "punteggio deve essere un intero")
if score < SCORE_MIN or score > SCORE_MAX or score % SCORE_STEP:
raise ApiError(400, "punteggio fuori dai limiti plausibili")
return {"name": name, "player_id": player_id, "submission_id": submission_id.lower(),
"score": score}
def submit(payload: dict, ip: str) -> tuple:
data = validate_submission(payload)
conn = connect()
try:
# L'idempotenza si verifica PRIMA dei rate limit: il retry dello stesso invio non e'
# carico nuovo e non deve essere bloccato dal limite per giocatore o per IP.
existing = conn.execute("SELECT * FROM scores WHERE submission_id = ?",
(data["submission_id"],)).fetchone()
if existing is not None:
return 200, {"ok": True, "duplicate": True, "id": existing["id"],
"rank": rank_of(conn, existing["score"], existing["created_at"]),
"total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]}
with _lock:
check_rate(_post_by_ip, ip, 3600.0, POST_PER_HOUR)
last = _last_by_player.get(data["player_id"])
if last is not None and time.monotonic() - last < POST_MIN_INTERVAL:
raise ApiError(429, "hai appena inviato un punteggio")
# Solo qui l'invio e' accettato: ora si consuma il budget.
record_hit(_post_by_ip, ip)
_last_by_player[data["player_id"]] = time.monotonic()
row_id = str(uuid.uuid4()) # id univoco casuale della riga, generato dal server
created = now_iso()
try:
conn.execute(
"INSERT INTO scores (id, player_id, submission_id, name, score, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(row_id, data["player_id"], data["submission_id"], data["name"], data["score"], created))
conn.commit()
except sqlite3.IntegrityError:
# Due invii identici in parallelo: il secondo e' un duplicato, non un errore.
existing = conn.execute("SELECT * FROM scores WHERE submission_id = ?",
(data["submission_id"],)).fetchone()
if existing is None:
raise
return 200, {"ok": True, "duplicate": True, "id": existing["id"],
"rank": rank_of(conn, existing["score"], existing["created_at"]),
"total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]}
return 201, {"ok": True, "duplicate": False, "id": row_id,
"rank": rank_of(conn, data["score"], created),
"total": conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"]}
finally:
conn.close()
def leaderboard(limit: int, player_id: str | None) -> dict:
conn = connect()
try:
total = int(conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"])
rows = conn.execute(
"SELECT id, player_id, name, score, created_at FROM scores "
"ORDER BY score DESC, created_at ASC LIMIT ?", (limit,)).fetchall()
top = [{"rank": i + 1, "id": r["id"], "player_id": r["player_id"], "name": r["name"],
"score": int(r["score"]), "created_at": r["created_at"]}
for i, r in enumerate(rows)]
me = None
if player_id:
best = conn.execute(
"SELECT * FROM scores WHERE player_id = ? ORDER BY score DESC, created_at ASC LIMIT 1",
(player_id,)).fetchone()
if best is not None:
me = {"best": int(best["score"]), "name": best["name"],
"created_at": best["created_at"],
"rank": rank_of(conn, best["score"], best["created_at"]),
"games": int(conn.execute(
"SELECT COUNT(*) AS n FROM scores WHERE player_id = ?",
(player_id,)).fetchone()["n"])}
return {"top": top, "total": total, "limit": limit, "me": me}
finally:
conn.close()
class Handler(BaseHTTPRequestHandler):
server_version = "zombie-fps-scores/" + VERSION
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None:
print("[%s] %s %s" % (now_iso(), self.address_string(), fmt % args), flush=True)
def _send(self, status: int, payload: dict, extra=None) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def _fail(self, error: ApiError) -> None:
extra = {"Retry-After": "60"} if error.status == 429 else None
self._send(error.status, {"error": error.message}, extra)
def _client_ip(self) -> str:
# nginx e' l'unico ingresso: usa l'IP reale inoltrato, con ripiego sul peer.
forwarded = self.headers.get("X-Real-IP") or self.headers.get("X-Forwarded-For", "")
return forwarded.split(",")[0].strip() or self.client_address[0]
def _read_json(self) -> dict:
length = self.headers.get("Content-Length")
try:
size = int(length) if length is not None else 0
except ValueError:
raise ApiError(400, "Content-Length non valido")
if size > MAX_BODY_BYTES:
raise ApiError(413, "corpo troppo grande")
raw = self.rfile.read(size) if size else b""
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise ApiError(400, "JSON non valido")
def do_GET(self) -> None:
parsed = urlparse(self.path)
path, query = parsed.path.rstrip("/") or "/", parse_qs(parsed.query)
try:
if path == "/health":
conn = connect()
try:
rows = int(conn.execute("SELECT COUNT(*) AS n FROM scores").fetchone()["n"])
finally:
conn.close()
self._send(200, {"ok": True, "service": SERVICE, "version": VERSION, "rows": rows})
return
if path == "/scores":
with _lock:
check_rate(_get_by_ip, self._client_ip(), 60.0, GET_PER_MINUTE)
try:
limit = int(query.get("limit", [str(TOP_DEFAULT)])[0])
except ValueError:
raise ApiError(400, "limit non valido")
limit = max(1, min(TOP_MAX, limit))
player = query.get("player", [None])[0]
if player and not ID_RE.match(player):
raise ApiError(400, "player non valido")
payload = leaderboard(limit, player)
with _lock:
record_hit(_get_by_ip, self._client_ip()) # solo una lettura servita
self._send(200, payload)
return
raise ApiError(404, "percorso non trovato")
except ApiError as error:
self._fail(error)
except Exception as error: # qualunque altro errore: 500 JSON
print("ERRORE /%s: %r" % (path, error), flush=True)
self._send(500, {"error": "errore interno"})
def do_POST(self) -> None:
path = urlparse(self.path).path.rstrip("/") or "/"
try:
if path != "/scores":
raise ApiError(404, "percorso non trovato")
if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json":
raise ApiError(415, "Content-Type deve essere application/json")
status, payload = submit(self._read_json(), self._client_ip())
self._send(status, payload)
except ApiError as error:
self._fail(error)
except Exception as error:
print("ERRORE POST %s: %r" % (path, error), flush=True)
self._send(500, {"error": "errore interno"})
def do_HEAD(self) -> None:
self.do_GET()
def do_OPTIONS(self) -> None: # solo per lo sviluppo locale
origin = self.headers.get("Origin", "")
allowed = os.environ.get("DEV_CORS_ORIGINS", "")
if origin and origin in [o.strip() for o in allowed.split(",") if o.strip()]:
self._send(204, {}, {"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "600"})
else:
self._send(204, {})
def main() -> None:
init_db()
server = ThreadingHTTPServer((BIND, PORT), Handler)
server.daemon_threads = True
print("%s %s in ascolto su %s:%d (db %s)" % (SERVICE, VERSION, BIND, PORT, DB_PATH), flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
# Zombie Room FPS — API della classifica.
# In ascolto SOLO su loopback: l'unico ingresso pubblico e' nginx (reverse proxy
# su /api/zombie-fps/). Per avviare: docker compose up -d --build
services:
scores:
build: .
image: zombie-fps-scores:1.0.0
container_name: zombie-fps-scores
restart: unless-stopped
init: true
# Il processo gira con l'uid dell'utente host che possiede ./data: cosi' il
# database e i file WAL restano leggibili e backuppabili senza sudo.
user: "1000:1000"
environment:
DATABASE_PATH: /data/scores.db
PORT: "8791"
MAX_BODY_BYTES: "2048"
volumes:
# Si monta la DIRECTORY: con WAL accanto al database ci sono anche -wal e -shm.
- ./data:/data
ports:
- "127.0.0.1:8791:8791"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stop_grace_period: 20s
logging:
driver: json-file
options:
max-size: "5m"
max-file: "3"