From 0a8f5f62f3f6da56011b83212071eb59a288cf39 Mon Sep 17 00:00:00 2001 From: "Alicia (BSN)" Date: Sun, 21 Jun 2026 22:13:29 +0000 Subject: [PATCH] feat: Spiele-Empfehlungs-Engine mit Scoring-Algorithmus --- empfehlung_service.py | 84 +++++++++++++++++++++++++++ game_database.py | 132 ++++++++++++++++++++++++++++++++++++++++++ test_empfehlung.py | 117 +++++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 empfehlung_service.py create mode 100644 game_database.py create mode 100644 test_empfehlung.py diff --git a/empfehlung_service.py b/empfehlung_service.py new file mode 100644 index 0000000..bad977f --- /dev/null +++ b/empfehlung_service.py @@ -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] diff --git a/game_database.py b/game_database.py new file mode 100644 index 0000000..a52841c --- /dev/null +++ b/game_database.py @@ -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 diff --git a/test_empfehlung.py b/test_empfehlung.py new file mode 100644 index 0000000..d75fd48 --- /dev/null +++ b/test_empfehlung.py @@ -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