slop game oder so

This commit is contained in:
2026-09-24 17:57:54 +02:00
commit 4c997eee21
166 changed files with 14976 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
"""Validierung und Plausibilitätsprüfung von Highscore-Einreichungen.
Reine Funktionen ohne Flask-Abhängigkeit, damit sie isoliert testbar sind.
"""
from __future__ import annotations
import logging
import re
import unicodedata
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
NAME_MAX_LENGTH = 16
#: Obergrenze für die Rohlänge, bevor überhaupt bereinigt wird – schützt vor
#: unnötiger Arbeit an absurd langen Eingaben.
NAME_RAW_MAX_LENGTH = 200
#: Alles außer Buchstaben, Ziffern, Unterstrich, Leerzeichen und ein paar harmlosen
#: Satzzeichen wird entfernt. Umlaute und andere Unicode-Buchstaben bleiben erhalten.
_DISALLOWED_NAME_CHARS = re.compile(r"[^\w .\-'!?]")
RUN_TOKEN_MAX_LENGTH = 512
class SubmissionError(ValueError):
"""Ungültige Einreichung; ``fields`` ordnet Feldnamen eine lesbare Meldung zu."""
def __init__(self, fields: dict[str, str]) -> None:
super().__init__("; ".join(f"{key}: {msg}" for key, msg in fields.items()))
self.fields = fields
@dataclass(frozen=True)
class ScoreSubmission:
"""Eine formal gültige Highscore-Einreichung."""
name: str
score: int
wave: int
duration_s: int
run_token: str
@dataclass(frozen=True)
class PlausibilityRules:
"""Grenzwerte für die Plausibilitätsprüfung (siehe ``config.GAME_RULE_DEFAULTS``)."""
score_max: int
score_flat_allowance: int
score_max_points_per_second: int
wave_max: int
wave_min_seconds: int
duration_max_seconds: int
duration_tolerance_seconds: int
@classmethod
def from_config(cls, config: Mapping[str, Any]) -> PlausibilityRules:
"""Erzeugt die Regeln aus einer Flask-Konfiguration."""
return cls(
score_max=int(config["SCORE_MAX"]),
score_flat_allowance=int(config["SCORE_FLAT_ALLOWANCE"]),
score_max_points_per_second=int(config["SCORE_MAX_POINTS_PER_SECOND"]),
wave_max=int(config["WAVE_MAX"]),
wave_min_seconds=int(config["WAVE_MIN_SECONDS"]),
duration_max_seconds=int(config["DURATION_MAX_SECONDS"]),
duration_tolerance_seconds=int(config["DURATION_TOLERANCE_SECONDS"]),
)
def sanitize_name(raw: str) -> str:
"""Bereinigt einen Spielernamen für Anzeige und Speicherung.
Schritte: Unicode-Normalisierung (NFKC, macht z. B. Vollbreiten-Zeichen zu normalen),
Entfernen von Steuer- und Formatzeichen (u. a. unsichtbare Zero-Width- und
Bidi-Zeichen, mit denen man Namen optisch fälschen könnte), Entfernen nicht
erlaubter Zeichen und Zusammenfassen von Leerraum.
Die Ausgabe im Frontend wird trotzdem immer als Text (nicht als HTML) gesetzt –
die Bereinigung ist eine zweite Verteidigungslinie, nicht die einzige.
"""
text = unicodedata.normalize("NFKC", raw)
# Kategorie „C*“ = Steuer-, Format-, Surrogat-, private und nicht zugewiesene Zeichen.
text = "".join(ch for ch in text if not unicodedata.category(ch).startswith("C"))
text = _DISALLOWED_NAME_CHARS.sub("", text)
return " ".join(text.split())
def normalize_for_blocklist(text: str) -> str:
"""Vergleichsform für die Sperrliste: klein geschrieben, nur Buchstaben und Ziffern.
So lassen sich gesperrte Begriffe nicht mit Leerzeichen, Punkten oder anderer
Groß-/Kleinschreibung umgehen („M.a.x Muster“ entspricht „maxmuster“).
"""
folded = unicodedata.normalize("NFKC", text).casefold()
return "".join(ch for ch in folded if ch.isalnum())
def load_blocklist(path: str | Path) -> list[str]:
"""Liest die Sperrliste (UTF-8, ein Begriff pro Zeile, ``#`` leitet Kommentare ein).
Fehlt die Datei, ist nichts gesperrt. Ist sie unlesbar, wird gewarnt, statt die
Einreichung scheitern zu lassen.
"""
try:
text = Path(path).read_text(encoding="utf-8")
except FileNotFoundError:
return []
except (OSError, UnicodeDecodeError) as exc:
logger.warning("Sperrliste %s konnte nicht gelesen werden: %s", path, exc)
return []
terms = []
for line in text.splitlines():
entry = line.strip()
if entry and not entry.startswith("#"):
normalized = normalize_for_blocklist(entry)
if normalized:
terms.append(normalized)
return terms
def is_name_blocked(name: str, terms: Iterable[str]) -> bool:
"""True, wenn ein gesperrter Begriff im (normalisierten) Namen vorkommt."""
normalized = normalize_for_blocklist(name)
return any(term in normalized for term in terms)
def _strict_int(value: object) -> int | None:
"""Nur echte Ganzzahlen: ``True`` ist in Python auch ein ``int``, aber kein Punktestand."""
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def parse_submission(payload: Mapping[str, Any], rules: PlausibilityRules) -> ScoreSubmission:
"""Prüft Typen und Wertebereiche einer Einreichung.
Raises:
SubmissionError: mit allen gefundenen Fehlern auf einmal, damit das Frontend
nicht Fehler für Fehler nachfragen muss.
"""
errors: dict[str, str] = {}
raw_name = payload.get("name")
name = ""
if not isinstance(raw_name, str):
errors["name"] = "Name fehlt oder ist kein Text."
elif len(raw_name) > NAME_RAW_MAX_LENGTH:
errors["name"] = f"Name darf höchstens {NAME_MAX_LENGTH} Zeichen lang sein."
else:
name = sanitize_name(raw_name)
if not name:
errors["name"] = "Name ist leer oder enthält nur unzulässige Zeichen."
elif len(name) > NAME_MAX_LENGTH:
errors["name"] = f"Name darf höchstens {NAME_MAX_LENGTH} Zeichen lang sein."
score = _strict_int(payload.get("score"))
if score is None or not 0 <= score <= rules.score_max:
errors["score"] = f"Punkte müssen eine ganze Zahl zwischen 0 und {rules.score_max} sein."
wave = _strict_int(payload.get("wave"))
if wave is None or not 1 <= wave <= rules.wave_max:
errors["wave"] = f"Welle muss eine ganze Zahl zwischen 1 und {rules.wave_max} sein."
duration_s = _strict_int(payload.get("duration_s"))
if duration_s is None or not 0 <= duration_s <= rules.duration_max_seconds:
errors["duration_s"] = (
f"Spieldauer muss eine ganze Zahl zwischen 0 und {rules.duration_max_seconds} sein."
)
run_token = payload.get("run_token")
if not isinstance(run_token, str) or not 0 < len(run_token) <= RUN_TOKEN_MAX_LENGTH:
errors["run_token"] = "Run-Token fehlt oder ist ungültig." # noqa: S105
if errors:
raise SubmissionError(errors)
# Nach der Prüfung oben sind alle Werte gesetzt; die asserts helfen nur dem Typprüfer.
assert score is not None and wave is not None and duration_s is not None # noqa: S101
assert isinstance(run_token, str) # noqa: S101
return ScoreSubmission(
name=name, score=score, wave=wave, duration_s=duration_s, run_token=run_token
)
def check_plausibility(
submission: ScoreSubmission, elapsed_s: float, rules: PlausibilityRules
) -> str | None:
"""Prüft, ob Punktestand, Welle und Dauer zusammenpassen können.
Args:
submission: Formal gültige Einreichung.
elapsed_s: Serverseitig gemessene Zeit seit Ausgabe des Run-Tokens.
rules: Grenzwerte.
Returns:
``None`` wenn plausibel, sonst eine kurze deutsche Begründung.
"""
# Die gemeldete (aktive) Spielzeit kann kürzer sein als die Token-Zeit, weil Pausen
# nicht zählen – aber nie länger.
if submission.duration_s > elapsed_s + rules.duration_tolerance_seconds:
return "Die gemeldete Spieldauer ist länger als die tatsächliche Rundenzeit."
# Jede überstandene Welle kostet mindestens die Pause bis zur nächsten.
min_duration = (submission.wave - 1) * rules.wave_min_seconds
if submission.duration_s + rules.duration_tolerance_seconds < min_duration:
return "Diese Welle ist in der gemeldeten Zeit nicht erreichbar."
max_score = (
rules.score_flat_allowance + submission.duration_s * rules.score_max_points_per_second
)
if submission.score > max_score:
return "Der Punktestand ist für die Spieldauer nicht plausibel."
return None