feat: Spiele-Empfehlungs-Engine mit Scoring-Algorithmus
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m56s

This commit is contained in:
Alicia (BSN)
2026-06-21 22:13:29 +00:00
parent 567cf7e63d
commit 0a8f5f62f3
3 changed files with 333 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
"""Empfehlungs-Service für Brettspiele."""
from game_database import SPIEL
FACTOR_KOMPLEXITAET: float = 33.34
FACTOR_DAUER: float = 33.33
FACTOR_INTERAKTION: float = 33.33
def score_berechnen(
spiel: SPIEL, praeferenzen: dict[str, float]
) -> float:
"""Berechnet den Match-Score für ein Spiel.
Bewertet das Spiel nach drei Dimensionen:
- Komplexität: je näher an Wunsch, desto besser
- Dauer: je kuerzer unter Wunsch, desto besser
- Interaktion (via max_spieler): je naeher an Wunsch, desto besser
Args:
spiel: Das zu bewertende Spiel-Dict.
praeferenzen: Nutzerpraefereenzen mit Schlüsseln
'komplexitaet', 'dauer', 'interaktion'.
Returns:
Score von 0 bis 100, 100 = perfekte Uebereinstimmung.
"""
punkte: float = 0.0
max_punkte: float = 0.0
if "komplexitaet" in praeferenzen:
wunsch = praeferenzen["komplexitaet"]
max_punkte += FACTOR_KOMPLEXITAET
diff = abs(spiel["komplexitaet"] - wunsch)
punkte += FACTOR_KOMPLEXITAET * max(0.0, 1.0 - diff * 0.5)
if "dauer" in praeferenzen:
wunsch = praeferenzen["dauer"]
max_punkte += FACTOR_DAUER
actual = spiel["dauer_min"]
if actual <= wunsch:
punkte += FACTOR_DAUER * (1.0 - (wunsch - actual) / max(wunsch, 1.0))
else:
punkte += FACTOR_DAUER * 0.15
if "interaktion" in praeferenzen:
wunsch = praeferenzen["interaktion"]
max_punkte += FACTOR_INTERAKTION
diff = abs(spiel["max_spieler"] - wunsch)
punkte += FACTOR_INTERAKTION * max(0.0, 1.0 - diff / 6.0)
if max_punkte == 0.0:
return 0.0
return round(punkte / max_punkte * 100.0, 2)
def empfehlungen_erstellen(
spiele: list[SPIEL],
praeferenzen: dict[str, float],
limit: int = 5,
) -> list[tuple[str, float]]:
"""Erstellt Empfehlungen basierend auf Spielerpraefereenzen.
Berechnet fuer jedes Spiel einen Score und gibt die Top-N
Spiele zurueck, sortiert nach Score absteigend.
Args:
spiele: Liste aller verfügbaren Spiele.
praeferenzen: Nutzerpraefereenzen fuer das Scoring.
limit: Maximale Anzahl zurückgegebener Empfehlungen.
Returns:
Liste von (Name, Score)-Tuples, sortiert nach Score absteigend.
"""
scored: list[tuple[str, float]] = []
for spiel in spiele:
name: str = spiel.get("name", "Unbekannt")
punktzahl: float = score_berechnen(spiel, praeferenzen)
scored.append((name, punktzahl))
scored.sort(key=lambda entry: entry[1], reverse=True)
return scored[:limit]
+132
View File
@@ -0,0 +1,132 @@
"""Spiele-Datenbank fuer die Empfehlungs-Engine."""
from typing import TypedDict
class SPIEL(TypedDict):
"""Struktur einer Spiel-Zeile."""
name: str
min_spieler: int
max_spieler: int
dauer_min: int
komplexitaet: float
kategorien: list[str]
MIN_SPIELER_DEFAULT = 1
MAX_SPIELER_DEFAULT = 10
def spiele_laden() -> list[SPIEL]:
"""Lädt eine vordefinierte Liste von Brettspielen.
Returns:
Liste von Spiel-Dictionaries mit mindestens 8 Spielen.
"""
return [
{
"name": "Catan",
"min_spieler": 3,
"max_spieler": 4,
"dauer_min": 60,
"komplexitaet": 2.5,
"kategorien": ["Strategie", "Aufbau", "Handel"],
},
{
"name": "Carcassonne",
"min_spieler": 2,
"max_spieler": 5,
"dauer_min": 45,
"komplexitaet": 1.5,
"kategorien": ["Familienspiel", "Bauen", "Karte"],
},
{
"name": "Pandemic",
"min_spieler": 2,
"max_spieler": 4,
"dauer_min": 45,
"komplexitaet": 2.4,
"kategorien": ["Strategie", "Kooperativ", "Abenteuer"],
},
{
"name": "Codenames",
"min_spieler": 4,
"max_spieler": 8,
"dauer_min": 15,
"komplexitaet": 1.3,
"kategorien": ["Partyspiel", "Worte", "Team"],
},
{
"name": "Wunderland",
"min_spieler": 2,
"max_spieler": 4,
"dauer_min": 60,
"komplexitaet": 2.5,
"kategorien": ["Strategie", "Worte", "Kreativ"],
},
{
"name": "Ticket to Ride",
"min_spieler": 2,
"max_spieler": 5,
"dauer_min": 60,
"komplexitaet": 1.8,
"kategorien": ["Familienspiel", "Reisen", "Bauen"],
},
{
"name": "Dixit",
"min_spieler": 3,
"max_spieler": 6,
"dauer_min": 30,
"komplexitaet": 1.2,
"kategorien": ["Partyspiel", "Träume", "Kreativ"],
},
{
"name": "Wingspan",
"min_spieler": 1,
"max_spieler": 5,
"dauer_min": 70,
"komplexitaet": 3.1,
"kategorien": ["Strategie", "Aufbau", "Natur"],
},
]
def spiele_filtern(
spiele: list[SPIEL],
spieler_count: int | None = None,
kategorien: list[str] | None = None,
max_dauer: int | None = None,
) -> list[SPIEL]:
"""Filtert Spiele nach Spieleranzahl, Kategorien und Dauer.
Jeder Filter ist optional (None = kein Filter). Nur Spiele,
die alle aktiven Filter erfüllen, werden zurückgegeben.
Args:
spiele: Liste aller Spiele, die gefiltert werden sollen.
spieler_count: Gesuchte Spieleranzahl.
kategorien: Liste der Kategorien, die vorhanden sein müssen.
max_dauer: Maximale Spieldauer in Minuten.
Returns:
Gefilterte Liste der Spiele.
"""
ergebnis: list[SPIEL] = []
for spiel in spiele:
if spieler_count is not None:
if not (spiel["min_spieler"] <= spieler_count <= spiel["max_spieler"]):
continue
if kategorien is not None:
spiel_kat = spiel.get("kategorien", [])
if not any(kat in spiel_kat for kat in kategorien):
continue
if max_dauer is not None:
if spiel.get("dauer_min", 999) > max_dauer:
continue
ergebnis.append(spiel)
return ergebnis
+117
View File
@@ -0,0 +1,117 @@
"""Tests fuer die Spiele-Empfehlungs-Engine."""
import pytest
from game_database import SPIEL, spiele_laden, spiele_filtern
from empfehlung_service import score_berechnen, empfehlungen_erstellen
EXAKT_SPIEL: SPIEL = {
"name": "Testspiel",
"min_spieler": 2,
"max_spieler": 4,
"dauer_min": 45,
"komplexitaet": 2.5,
"kategorien": ["Strategie"],
}
class TestSpielFilter:
"""Tests fuer spiele_filtern."""
def test_filter_spieler_count(self) -> None:
"""Spieler count=4: alle Spiele mit passendem Bereich."""
alle = spiele_laden()
ergebnis = spiele_filtern(alle, spieler_count=4)
assert len(ergebnis) > 0
for spiel in ergebnis:
assert spiel["min_spieler"] <= 4 <= spiel["max_spieler"]
def test_filter_kategorien(self) -> None:
"""Kategorie-Filter: nur Spiele mit Strategie-Kategorie."""
alle = spiele_laden()
ergebnis = spiele_filtern(alle, kategorien=["Strategie"])
assert len(ergebnis) > 0
for spiel in ergebnis:
assert "Strategie" in spiel.get("kategorien", [])
def test_filter_max_dauer(self) -> None:
"""Maximaldauer-Filter: keine Spiele laenger als 60 Minuten."""
alle = spiele_laden()
ergebnis = spiele_filtern(alle, max_dauer=60)
for spiel in ergebnis:
assert spiel.get("dauer_min", 999) <= 60
def test_filter_kombiniert(self) -> None:
"""Kombinierter Filter: Spieler, Kategorie, Dauer."""
alle = spiele_laden()
ergebnis = spiele_filtern(
alle,
spieler_count=4,
kategorien=["Strategie"],
max_dauer=60,
)
assert len(ergebnis) > 0
for spiel in ergebnis:
assert spiel["min_spieler"] <= 4 <= spiel["max_spieler"]
assert "Strategie" in spiel.get("kategorien", [])
assert spiel.get("dauer_min", 999) <= 60
class TestScoreBerechnen:
"""Tests fuer score_berechnen."""
def test_score_exakte_uebereinstimmung(self) -> None:
"""Exakte Uebereinstimmung: Testspiel scoret perfekt (100 Punkte)."""
alle = spiele_laden()
wuensche = {
"komplexitaet": 2.5,
"dauer": 45,
"interaktion": 4.0,
}
exakt_score = score_berechnen(EXAKT_SPIEL, wuensche)
assert exakt_score == pytest.approx(100.0, abs=0.1)
for spiel in alle:
punktzahl = score_berechnen(spiel, wuensche)
assert punktzahl < exakt_score
def test_score_ferne_werte(self) -> None:
"""Fernte Werte: deutlich geringerer Score als passende Werte."""
ferne = {"komplexitaet": 5.0, "dauer": 5, "interaktion": 10}
exakte_prefs = {"komplexitaet": 2.5, "dauer": 45, "interaktion": 4}
ferne_score = score_berechnen(EXAKT_SPIEL, ferne)
exakte_score = score_berechnen(EXAKT_SPIEL, exakte_prefs)
assert ferne_score < 20
assert ferne_score < exakte_score
class TestEmpfehlungenErstellen:
"""Tests fuer empfehlungen_erstellen."""
def test_empfehlungen_sind_sortiert(self) -> None:
"""Empfehlungen: Score-Werte sind absteigend sortiert."""
alle = spiele_laden()
wuensche = {"komplexitaet": 2.5, "dauer": 45, "interaktion": 4}
ergebnis = empfehlungen_erstellen(alle, wuensche)
for i in range(len(ergebnis) - 1):
assert ergebnis[i][1] >= ergebnis[i + 1][1]
def test_empfehlungen_respektiert_limit(self) -> None:
"""Empfehlungen: Anzahl entspricht dem Limit."""
alle = spiele_laden()
wuensche = {"komplexitaet": 2.5, "dauer": 45, "interaktion": 4}
ergebnis_3 = empfehlungen_erstellen(alle, wuensche, limit=3)
ergebnis_5 = empfehlungen_erstellen(alle, wuensche, limit=5)
assert len(ergebnis_3) == 3
assert len(ergebnis_5) == 5