diff --git a/.planer/architektur.md b/.planer/architektur.md new file mode 100644 index 0000000..a5cf8e5 --- /dev/null +++ b/.planer/architektur.md @@ -0,0 +1,44 @@ +# Architektur: tft + +## Komponente: API-Schnittstelle +beschreibung: Stellt HTTP-Endpunkte bereit, über die TFT-Partien gesteuert, erstellt und ausgelesen werden können. +einheiten: backend/tft/api/app.py::ActionRequest, backend/tft/api/app.py::_load_game_deps, backend/tft/api/app.py::action, backend/tft/api/app.py::health, backend/tft/api/app.py::new_game, backend/tft/api/app.py::serialize, backend/tft/api/app.py::state, backend/tft/api/app.py::unit_view, backend/tft/api/app.py::upgrade_hint +beziehungen: +- nutzt: Spielsimulation +- wird-genutzt-von: Kommandozeile + +## Komponente: Kommandozeile +beschreibung: Bietet das CLI-Werkzeug `tft` und verzweigt Subcommands auf die passenden Pipeline-Funktionen. +einheiten: backend/tft/cli.py::current_set, backend/tft/cli.py::main +beziehungen: +- nutzt: API-Schnittstelle + +## Komponente: Konstanten +beschreibung: Lädt und validiert die TOML-Konstantendatei eines TFT-Sets. +einheiten: backend/tft/constants/loader.py::load_constants, backend/tft/constants/loader.py::validate + +## Komponente: Match-Datenpersistenz +beschreibung: Stellt die SQLite-Verbindung bereit und crawlt, extrahiert und validiert gewertete TFT-Matches. +einheiten: backend/tft/db.py::connect, backend/tft/matches/crawl.py::crawl, backend/tft/matches/extract.py::extract_all, backend/tft/matches/extract.py::extract_match, backend/tft/matches/extract.py::patch_of, backend/tft/matches/extract.py::validate_ids, backend/tft/matches/riot.py::RiotClient, backend/tft/matches/riot.py::_load_env, backend/tft/matches/riot.py::api_key +beziehungen: +- nutzt: Statische Daten + +## Komponente: Statische Daten +beschreibung: Bezieht, parst und versioniert die normalisierten TFT-Rohdaten von Community Dragon. +einheiten: backend/tft/staticdata/fetch.py::fetch_static, backend/tft/staticdata/fetch.py::load_static, backend/tft/staticdata/parse.py::augment_tier, backend/tft/staticdata/parse.py::detect_set, backend/tft/staticdata/parse.py::icon_url, backend/tft/staticdata/parse.py::parse, backend/tft/staticdata/parse.py::render_desc, backend/tft/staticdata/parse.py::resolve_spell +beziehungen: +- wird-genutzt-von: Match-Datenpersistenz + +## Komponente: Modell und Artefakt +beschreibung: Baut das versionierte Analyse-Artefakt aus Statikdaten und erlernt Stärke-Multiplikatoren aus Endboards. +einheiten: backend/tft/model/artifact.py::_augment_specs, backend/tft/model/artifact.py::build, backend/tft/model/artifact.py::component_pool, backend/tft/model/artifact.py::craftable_items, backend/tft/model/artifact.py::load, backend/tft/model/artifact.py::recipes, backend/tft/model/artifact.py::save, backend/tft/model/artifact.py::spell_dps_cap, backend/tft/model/augments.py::_num, backend/tft/model/augments.py::build_spec, backend/tft/model/augments.py::build_specs, backend/tft/model/baseline.py::classify_role, backend/tft/model/baseline.py::unit_value, backend/tft/model/calibrate.py::_holdout_scores, backend/tft/model/calibrate.py::_ranks, backend/tft/model/calibrate.py::board_from_row, backend/tft/model/calibrate.py::calibrate, backend/tft/model/calibrate.py::spearman, backend/tft/model/learn.py::_multiplier, backend/tft/model/learn.py::learn, backend/tft/model/learn.py::learn_from_db, backend/tft/model/score.py::active_trait_tiers, backend/tft/model/score.py::board_profiles, backend/tft/model/score.py::score_board, backend/tft/model/score.py::unit_profile, backend/tft/model/statsheet.py::_apply_item_effects, backend/tft/model/statsheet.py::_empty_acc, backend/tft/model/statsheet.py::_fraction, backend/tft/model/statsheet.py::item_is_modeled, backend/tft/model/statsheet.py::trait_buffs, backend/tft/model/statsheet.py::unit_stats, backend/tft/paths.py::artifact_dir, backend/tft/paths.py::latest_artifact_pointer, backend/tft/paths.py::latest_static_pointer, backend/tft/paths.py::static_dir +beziehungen: +- nutzt: Statische Daten +- wird-genutzt-von: Spielsimulation + +## Komponente: Spielsimulation +beschreibung: Simuliert vollständige TFT-Partien als endliche Zustandsmaschine inklusive Kampf, Wirtschaft, Shop und Autoplay. +einheiten: backend/tft/sim/autoplay.py::play_afk, backend/tft/sim/autoplay.py::play_econ, backend/tft/sim/autoplay.py::run, backend/tft/sim/combat.py::FightResult, backend/tft/sim/combat.py::_queue, backend/tft/sim/combat.py::_suffix_off, backend/tft/sim/combat.py::fight, backend/tft/sim/combat.py::player_damage, backend/tft/sim/economy.py::apply_xp, backend/tft/sim/economy.py::interest, backend/tft/sim/economy.py::round_income, backend/tft/sim/economy.py::streak_gold, backend/tft/sim/economy.py::xp_to_next, backend/tft/sim/game.py::Game, backend/tft/sim/game.py::_delegate, backend/tft/sim/game.py::pair_players, backend/tft/sim/player.py::InvalidAction, backend/tft/sim/player.py::PlayerState, backend/tft/sim/player.py::buy, backend/tft/sim/player.py::buy_xp, backend/tft/sim/player.py::equip, backend/tft/sim/player.py::fill_board, backend/tft/sim/player.py::grab_carousel_unit, backend/tft/sim/player.py::grant_loot, backend/tft/sim/player.py::merge, backend/tft/sim/player.py::move, backend/tft/sim/player.py::new_player, backend/tft/sim/player.py::pick_augment, backend/tft/sim/player.py::refresh_shop, backend/tft/sim/player.py::reroll, backend/tft/sim/player.py::score, backend/tft/sim/player.py::sell, backend/tft/sim/player.py::unit_worth, backend/tft/sim/pool.py::Pool, backend/tft/sim/rounds.py::schedule, backend/tft/sim/shop.py::roll +beziehungen: +- nutzt: Modell und Artefakt +- wird-genutzt-von: API-Schnittstelle \ No newline at end of file diff --git a/.planer/einheiten/backend__tft__api__app.py.md b/.planer/einheiten/backend__tft__api__app.py.md new file mode 100644 index 0000000..b53ad43 --- /dev/null +++ b/.planer/einheiten/backend__tft__api__app.py.md @@ -0,0 +1,107 @@ +# Einheiten: backend/tft/api/app.py +stand: f41ac76b5789 + +## backend/tft/api/app.py::ActionRequest +beschreibung: Definiert das Pydantic-Schema für eingehende Aktions-Requests einer TFT-Spielpartie. +input: HTTP-Request-Body mit den Aktions-Feldern action, slot, where, idx, item_idx, board_idx, choice +output: Ein ActionRequest-Modell mit validierten, optional typisierten Aktions-Parametern +entscheidungen: +- Sämtliche Aktions-Parameter außer action sind als optional deklariert + beleg: "slot: int | None = None" +- Die Modell-Klasse verwendet Pydantic-BaseModel zur automatischen Validierung + beleg: "class ActionRequest(BaseModel):" +kanten: + +## backend/tft/api/app.py::health +beschreibung: Stellt einen Health-Check-Endpunkt zur Verfügung, der den Betriebszustand des Dienstes meldet. +input: HTTP-GET-Aufruf auf /health +output: Ein JSON-Objekt mit status="ok" +entscheidungen: +- Der Endpunkt ist über den Pfad /health erreichbar + beleg: "@app.get(\"/health\")" +- Der Status wird statisch als "ok" zurückgegeben + beleg: "return {\"status\": \"ok\"}" +kanten: + +## backend/tft/api/app.py::_load_game_deps +beschreibung: Lädt die für eine Spielsitzung benötigten statischen Set-Daten und Konfigurationswerte. +input: Keine Argumente; liest den Static-Pointer aus dem Dateisystem +output: Ein Tupel aus geladenem Artifact-Set und Konfigurationswerten +entscheidungen: +- Bei fehlendem Static-Pointer wird ein HTTP-503-Fehler ausgelöst + beleg: "raise HTTPException(503, \"no static data: run `tft.cli fetch-static` first\")" +- Die benötigten Submodule werden lokal erst zur Laufzeit importiert + beleg: "from tft.constants.loader import load_constants" +kanten: + +## backend/tft/api/app.py::new_game +beschreibung: Erstellt eine neue TFT-Spiellauf-Instanz und registriert sie im globalen Spiellauf-Register. +input: Optionaler Seed-Wert für die Initialisierung des Zufallsgenerators +output: Ein JSON-Objekt mit der frisch vergebenen game_id und dem serialisierten Spielzustand +entscheidungen: +- Die Spiele werden in einem prozessglobalen Dictionary GAMES gehalten + beleg: "GAMES[game_id] = Game(artifact, cfg, seed=seed)" +- Die game_id ist eine 8-stelligen Hex-Kürzel einer UUID + beleg: "game_id = uuid.uuid4().hex[:8]" +kanten: + +## backend/tft/api/app.py::state +beschreibung: Liefert den serialisierten Spielzustand einer bestehenden Spiellauf-Instanz. +input: Eine game_id aus der URL +output: Ein JSON-Objekt mit dem Spielzustand oder ein 404-Fehler +entscheidungen: +- Unbekannte Spiele führen zu einem 404-Fehler statt zu einem leeren Zustand + beleg: "raise HTTPException(404, \"unknown game\")" +- Der Endpunkt ist als GET auf /api/game/{game_id} definiert + beleg: "@app.get(\"/api/game/{game_id}\")" +kanten: + +## backend/tft/api/app.py::action +beschreibung: Interpretiert eine Aktions-Anfrage und leitet sie an die passende Game-Methode weiter. +input: Eine game_id sowie ein ActionRequest mit Aktionstyp und Parametern +output: Ein serialisierter Spielzustand nach Ausführung oder ein 4xx-Fehler +entscheidungen: +- Unbekannte Aktionen werden als 400-Fehler abgewiesen + beleg: "raise HTTPException(400, f\"unknown action {req.action}\")" +- Ungültige Aktionen der Domäne werden als 400-Fehler übersetzt + beleg: "except InvalidAction as e:" +- Fehlende Parameter werden als 400-Fehler mit Hinweis auf die Aktion gemeldet + beleg: "raise HTTPException(400, f\"missing parameters for action {req.action}\")" +kanten: + +## backend/tft/api/app.py::upgrade_hint +beschreibung: Berechnet, in welche Sternstufe eine Einheit durch einen weiteren Kauf aufsteigen würde. +input: Das Game-Objekt sowie der api_name einer Einheit +output: 0 für kein Upgrade, 2 für einen 2-Sterne- oder 3 für einen 3-Sterne-Aufstieg +entscheidungen: +- Bereits 3-Sterne-Einheiten werden vom Upgrade-Pfad ausgeschlossen + beleg: "if u[\"api_name\"] == api and u[\"stars\"] < 3" +- Höhere Sterne zählen mit Stern-Eskalation als 3^(s-1) Kopien + beleg: "sum(3 ** (u[\"stars\"] - 1) for u in owned)" +kanten: + +## backend/tft/api/app.py::unit_view +beschreibung: Projiziert eine interne Einheit-Darstellung in die API-Antwortstruktur. +input: Ein Game-Objekt sowie eine Einheit-Dictionary-Repräsentation +output: Ein JSON-Objekt mit Anzeigename, Kosten, Sternen, Traits, Icon und getragenen Items +entscheidungen: +- Fehlende Anzeigenamen fallen auf den api_name zurück + beleg: "\"name\": info.get(\"name\", u[\"api_name\"])" +- Item-Namen und -Icons werden tolerant gegen fehlende Einträge ausgelesen + beleg: "game.artifact[\"static\"][\"items\"].get(i, {}).get(\"name\", i)" +kanten: + +## backend/tft/api/app.py::serialize +beschreibung: Erzeugt das vollständige JSON-Spielbild inklusive Brett, Bank, Shop, Traits und Gegnern. +input: Ein Game-Objekt der laufenden Simulation +output: Ein umfangreiches Dictionary, das alle UI-relevanten Spielzustandsdaten bündelt +entscheidungen: +- Die Trait-Liste wird absteigend nach Tier und Count sortiert + beleg: "key=lambda x: (-x[\"tier\"], -x[\"count\"])" +- Gegner werden absteigend nach HP sortiert dargestellt + beleg: "key=lambda x: -x[\"hp\"]" +- Das Log wird auf die letzten acht Einträge begrenzt + beleg: "\"log\": game.log[-8:]" +- Aktive Trait-Tiers werden aus Brett-Einheiten berechnet + beleg: "tiers = active_trait_tiers(game.board, static[\"traits\"], static[\"units\"])" +kanten: diff --git a/.planer/einheiten/backend__tft__cli.py.md b/.planer/einheiten/backend__tft__cli.py.md new file mode 100644 index 0000000..bece2b0 --- /dev/null +++ b/.planer/einheiten/backend__tft__cli.py.md @@ -0,0 +1,34 @@ +# Einheiten: backend/tft/cli.py +stand: f41ac76b5789 + +## backend/tft/cli.py::current_set +beschreibung: Liefert die aktuell aktive TFT-Set-Nummer aus dem statischen Daten-Zeiger. +input: keine Argumente; liest den vom Modul `tft.paths` bereitgestellten latest-Pointer +output: `int` – die Set-Nummer aus dem gelesenen JSON +entscheidungen: +- Bricht hart ab, wenn noch keine statischen Daten existieren, statt einen Default zurückzugeben + beleg: "raise SystemExit(\"no static data yet: run `fetch-static` first\")" +- Bezieht den Set-Schlüssel ausschließlich aus dem Feld `set` des Pointer-JSONs + beleg: "return json.loads(pointer.read_text())[\"set\"]" +- Kapselt den Import von `tft.paths` lokal in der Funktion, um Modullade-Reihenfolge unabhängig zu halten + beleg: "from tft import paths" +kanten: +- wird-genutzt-von: backend/tft/cli.py::main + +## backend/tft/cli.py::main +beschreibung: Stellt die Kommandozeilen-Schnittstelle des `tft`-Tools bereit und verzweigt eingegebene Subcommands auf die passenden Pipeline-Funktionen. +input: Prozess-Argumente aus `argparse` (Subcommand + Optionen wie `--set`, `--policy`, `--games`) +output: Konsolen-Statusmeldungen bzw. Fehlerausgaben; bei `refresh` ruft es rekursiv Sub-Stages als Subprozess auf +entscheidungen: +- Erlaubt ausschließlich die registrierten Subcommands, indem `required=True` gesetzt wird + beleg: "sub = parser.add_subparsers(dest=\"command\", required=True)" +- Trennt Subcommand-Implementierungen per Lazy-Import innerhalb der `elif`-Zweige + beleg: "if args.command == \"fetch-static\":" +- Bricht den `refresh`-Lauf beim ersten nicht-null Returncode eines Subprozesses ab + beleg: "if result.returncode != 0:" +- Übergibt beim `autoplay`-Subcommand die Default-Politik `econ`, falls `--policy` fehlt + beleg: "default=\"econ\"" +- Nutzt beim `calibrate`-Pfad strikt Holdout-Daten via `holdout_only=True` + beleg: "holdout_only=True)" +kanten: +- wird-genutzt-von: backend/tft/cli.py::main diff --git a/.planer/einheiten/backend__tft__constants__loader.py.md b/.planer/einheiten/backend__tft__constants__loader.py.md new file mode 100644 index 0000000..9335cbe --- /dev/null +++ b/.planer/einheiten/backend__tft__constants__loader.py.md @@ -0,0 +1,34 @@ +# Einheiten: backend/tft/constants/loader.py +stand: f41ac76b5789 + +## backend/tft/constants/loader.py::load_constants +beschreibung: Lädt die TOML-Konstantendatei für ein gegebenes Set und gibt das eingelesene Dict zurück. +input: set_number (int) – die zu ladende Set-Nummer. +output: dict – die deserialisierten Konstanten aus der TOML-Datei. +entscheidungen: +- Fehlt die Set-Datei, bricht der Lader mit einer FileNotFoundError ab und gibt einen Hinweis zur Behebung. + beleg: "if not path.exists():" +- Die Datei wird im Binary-Modus geöffnet, da tomllib rohe Bytes erwartet. + beleg: "with path.open(\"rb\") as f:" +- Geladene Werte werden unmittelbar über validate einer Konsistenzprüfung unterzogen. + beleg: "validate(c)" +kanten: +- ruft-auf: backend/tft/constants/loader.py::validate + +## backend/tft/constants/loader.py::validate +beschreibung: Validiert die Struktur und Plausibilität der geladenen Konstanten und wirft bei Verstößen ValueError. +input: c (dict) – das zuvor aus TOML geladene Konstanten-Dict. +output: None – signalisiert Gültigkeit durch stilles Zurückkehren oder Auslösen einer Ausnahme. +entscheidungen: +- Jede Shop-Odds-Zeile muss genau 5 Einträge besitzen. + beleg: "if len(row) != 5:" +- Die Pool-Größenliste muss exakt 5 Kostenstufen umfassen. + beleg: "if len(c[\"pool\"][\"sizes\"]) != 5:" +- Die Anzahl der XP-Stufen-Übergänge muss zur maximalen Stufe passen (to_next = max_level − 1). + beleg: "if len(xp[\"to_next\"]) != xp[\"max_level\"] - 1:" +- Die streak_gold-Tabelle muss aufsteigend nach Streak-Länge sortiert sein. + beleg: "if streaks != sorted(streaks):" +- Jede Shop-Odds-Zeile muss in Summe genau 100 ergeben. + beleg: "if sum(row) != 100:" +kanten: +- wird-genutzt-von: backend/tft/constants/loader.py::load_constants diff --git a/.planer/einheiten/backend__tft__db.py.md b/.planer/einheiten/backend__tft__db.py.md new file mode 100644 index 0000000..0e5579a --- /dev/null +++ b/.planer/einheiten/backend__tft__db.py.md @@ -0,0 +1,17 @@ +# Einheiten: backend/tft/db.py +stand: f41ac76b5789 + +## backend/tft/db.py::connect +beschreibung: Stellt eine SQLite-Verbindung zur Match-Datenbank her und initialisiert das Schema. +input: impliziter Datenbankpfad aus tft.paths.MATCHES_DB +output: eine geöffnete sqlite3.Connection mit ausgeführtem SCHEMA +entscheidungen: +- Das Datenverzeichnis wird vor dem Verbindungsaufbau angelegt, falls es fehlt. + beleg: "paths.DATA.mkdir(parents=True, exist_ok=True)" +- Das Schema wird bei jeder Verbindung idempotent per executescript angewendet. + beleg: "conn.executescript(SCHEMA)" +- Die Tabellen nutzen CREATE TABLE IF NOT EXISTS, wodurch Reinitialisierungen gefahrlos sind. + beleg: "CREATE TABLE IF NOT EXISTS matches" +- candidates_log wird im Schema nicht definiert und damit bewusst ausgelassen. + beleg: "CREATE TABLE IF NOT EXISTS crawl_log" +kanten: diff --git a/.planer/einheiten/backend__tft__matches__crawl.py.md b/.planer/einheiten/backend__tft__matches__crawl.py.md new file mode 100644 index 0000000..5834c2e --- /dev/null +++ b/.planer/einheiten/backend__tft__matches__crawl.py.md @@ -0,0 +1,19 @@ +# Einheiten: backend/tft/matches/crawl.py +stand: f41ac76b5789 + +## backend/tft/matches/crawl.py::crawl +beschreibung: Crawlt gewertete TFT-Matches von Challengern/GM-Spielern und persistiert sie gefiltert nach Queue und Set-Nummer in der SQLite-Datenbank. +input: set_number (Ziel-Set-Nummer) und optionales limit (Maximalanzahl neuer Matches) +output: Anzahl der neu hinzugefügten Matches (int) +entscheidungen: +- Bereits vorhandene Match-IDs werden vorab in ein Set geladen, um Duplikate frühzeitig zu überspringen. + beleg: "seen = {row[0] for row in conn.execute(\"SELECT match_id FROM matches\")}" +- Nur Matches der gewerteten Queue 1100 werden übernommen, andere werden verworfen. + beleg: "if info.get(\"queue_id\", info.get(\"queueId\")) != RANKED_QUEUE:" +- Nur Matches des angeforderten tft_set_number werden persistiert, Fremd-Sets herausgefiltert. + beleg: "if info[\"tft_set_number\"] != set_number:" +- Nach jedem neu gespeicherten Match wird sofort committet, um Fortschritt nicht zu verlieren. + beleg: "conn.commit()" +- Ein täglicher crawl_log-Eintrag aktualisiert die Anzahl hinzugefügter Matches inkrementell. + beleg: "ON CONFLICT(day) DO UPDATE SET matches_added = matches_added + ?" +kanten: diff --git a/.planer/einheiten/backend__tft__matches__extract.py.md b/.planer/einheiten/backend__tft__matches__extract.py.md new file mode 100644 index 0000000..4133406 --- /dev/null +++ b/.planer/einheiten/backend__tft__matches__extract.py.md @@ -0,0 +1,61 @@ +# Einheiten: backend/tft/matches/extract.py +stand: f41ac76b5789 + +## backend/tft/matches/extract.py::patch_of +beschreibung: Leitet den zweistelligen Versions-Patch (z. B. „16.14") aus einem beliebigen `game_version`-String ab. +input: Ein `game_version`-String mit beliebigen Präfixen oder Trennzeichen. +output: Der Patch-Anteil als String der Form „x.y". +entscheidungen: +- Iteriert über Tokens statt blind zu splitten, um an einen tatsächlichen `x.y`-Token zu gelangen. + beleg: "for token in game_version.replace(\"(\", \" \").split():" +- Übernimmt nur die ersten beiden Punkt-Segmente und ignoriert alles dahinter. + beleg: "return \".\".join(digits.split(\".\")[:2])" +- Bevorzugt ein gefundes numerisches Token gegenüber dem Fallback auf das letzte whitespace-getrennte Wort. + beleg: "digits = token" +kanten: +- wird-genutzt-von: backend/tft/matches/extract.py::extract_match + +## backend/tft/matches/extract.py::extract_match +beschreibung: Überführt ein einzelnes rohes Match-Diktat in eine Liste von Endboard-Zeilen, indem es pro Teilnehmer die relevanten Felder in ein Tupel schreibt. +input: Ein `match`-Dictionary gemäß Riot-Match-Schema. +output: Eine Liste von Tupeln mit Match-ID, PUUID, Set-Nummer, Patch, Platzierung, Level, letzter Runde, Restgold und JSON-serialisierten Units/Traits/Augments. +entscheidungen: +- Serialisiert verschachtelte Strukturen (Units, Traits, Augments) vor dem Schreiben als JSON-String, um eine flache Tupel-Form zu behalten. + beleg: "json.dumps(p[\"units\"])," +- Liefert bei fehlendem `augments`-Feld eine leere Liste statt einen Fehler. + beleg: "json.dumps(p.get(\"augments\", []))," +- Bezieht den Patch je Match zentral aus `info` und nicht pro Teilnehmer neu berechnet. + beleg: "patch = patch_of(info[\"game_version\"])" +kanten: +- ruft-auf: backend/tft/matches/extract.py::patch_of +- wird-genutzt-von: backend/tft/matches/extract.py::extract_all + +## backend/tft/matches/extract.py::validate_ids +beschreibung: Zählt in den Endboard-Zeilen IDs (Units, Items, Traits, Augments), die nicht in den statischen Daten vorkommen, als Patch-Drift-Kanarienvogel. +input: Eine Liste von Endboard-Zeilen-Tupeln und ein `static`-Dictionary mit bekannten IDs. +output: Ein `Counter` mit Präfix-Schlüsseln `unit:`, `item:`, `trait:` und `augment:`, das unbekannte IDs samt Häufigkeit führt. +entscheidungen: +- Filtert Unit-Drift um erwartbar unbekannte Beschwörungen/PvE-Einheiten anhand von Namenshinweisen heraus. + beleg: "if not any(h in api.lower() for h in BENIGN_UNIT_HINTS):" +- Vereinigt Items und Augments zu einem einzigen `known_items`-Set, um Doppellookups zu vermeiden. + beleg: "known_items = set(static[\"items\"]) | set(static[\"augments\"])" +- Zählt bei Items nur Drift, wenn das Item dem aktuellen Set oder dem generischen `TFT_Item_`-Schema angehört. + beleg: "if item.startswith(\"TFT_Item_\") or item.startswith(set_prefix):" +kanten: +- nutzt: backend/tft/matches/extract.py::BENIGN_UNIT_HINTS +- wird-genutzt-von: backend/tft/matches/extract.py::extract_all + +## backend/tft/matches/extract.py::extract_all +beschreibung: Liest alle noch nicht verarbeiteten Roh-Matches aus der Datenbank, extrahiert ihre Endboard-Zeilen, prüft sie gegen die statischen Daten und schreibt sie persistent in die `endboards`-Tabelle. +input: Eine geöffnete DB-`connection` mit Schema `matches`/`endboards` und ein `static`-Dictionary mit bekannten IDs. +output: Ein Tupel `(n, total_unknown)` mit Anzahl geschriebener Zeilen und aggregiertem Drift-Counter. +entscheidungen: +- Selektiert nur Matches, deren `match_id` noch nicht in `endboards` vorkommt, um Doppelverarbeitung zu vermeiden. + beleg: "SELECT raw FROM matches WHERE match_id NOT IN (SELECT DISTINCT match_id FROM endboards)" +- Verwendet `INSERT OR IGNORE`, damit Wiederholungsläufe keine Duplikatfehler erzeugen. + beleg: "\"INSERT OR IGNORE INTO endboards VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"" +- Sammelt Drift in einem einzigen Counter statt pro Match, damit das Endergebnis meldbar bleibt. + beleg: "total_unknown.update(validate_ids(rows, static))" +kanten: +- ruft-auf: backend/tft/matches/extract.py::extract_match +- ruft-auf: backend/tft/matches/extract.py::validate_ids diff --git a/.planer/einheiten/backend__tft__matches__riot.py.md b/.planer/einheiten/backend__tft__matches__riot.py.md new file mode 100644 index 0000000..4026a9a --- /dev/null +++ b/.planer/einheiten/backend__tft__matches__riot.py.md @@ -0,0 +1,48 @@ +# Einheiten: backend/tft/matches/riot.py +stand: f41ac76b5789 + +## backend/tft/matches/riot.py::_load_env +beschreibung: Lädt Variablen aus backend/.env in os.environ, sofern die Datei existiert. +input: keine Argumente; liest backend/.env relativ zum Repo-Root. +output: füllt fehlende Umgebungsvariablen in os.environ (None). +entscheidungen: +- Nur vorhandene Schlüssel werden gesetzt, bestehende Werte bleiben unangetastet. + beleg: "os.environ.setdefault(key.strip(), value.strip())" +- Kommentarzeilen und Zeilen ohne "=" werden ignoriert. + beleg: "if line and not line.startswith(\"#\") and \"=\" in line:" +- Fehlt die .env-Datei, wird der Ladevorgang still beendet. + beleg: "if not env_file.exists():" + anker: paths +kanten: + +## backend/tft/matches/riot.py::api_key +beschreibung: Liefert den Riot-API-Key aus der Umgebung und bricht bei fehlendem Key ab. +input: keine Argumente; liest RIOT_API_KEY aus os.environ. +output: den Riot-API-Key als String oder beendet den Prozess via SystemExit. +entscheidungen: +- Vor dem Auslesen wird die .env-Datei nachgeladen. + beleg: "_load_env()" +- Ohne gesetzten Key wird das Programm mit klarer Anleitung beendet. + beleg: "raise SystemExit(" +- Der Fehlerhinweis nennt den Bezugsort backend/.env. + beleg: "put it in backend/.env" +kanten: +- ruft-auf: backend/tft/matches/riot.py::_load_env + +## backend/tft/matches/riot.py::RiotClient +beschreibung: Dünner Wrapper für die Riot-API mit Rate-Limiting, 429-Retry und nutzbaren TFT-Endpunkten. +input: Hostname, Pfad, Query-Parameter und ggf. PUUID/Match-ID des Aufrufers. +output: JSON-Antwort der Riot-API als dict oder list, bzw. aggregierte Ladder-Einträge. +entscheidungen: +- Vor jedem Request wird mindestens SLEEP Sekunden gewartet, um das Dev-Key-Limit einzuhalten. + beleg: "time.sleep(SLEEP)" +- Bei HTTP 429 wird anhand von Retry-After erneut gesendet. + beleg: "if resp.status_code == 429:" +- Bei 401/403 wird der Prozess mit Hinweis beendet statt eine Exception weiterzureichen. + beleg: "if resp.status_code in (401, 403):" +- Der API-Key wird als X-Riot-Token-Header übergeben. + beleg: "headers={\"X-Riot-Token\": self.key}, timeout=30" +- setdefault verhindert das Überschreiben vorhandener Env-Variablen. + beleg: "os.environ.setdefault(key.strip(), value.strip())" +kanten: +- ruft-auf: backend/tft/matches/riot.py::api_key diff --git a/.planer/einheiten/backend__tft__model__artifact.py.md b/.planer/einheiten/backend__tft__model__artifact.py.md new file mode 100644 index 0000000..f5f99b5 --- /dev/null +++ b/.planer/einheiten/backend__tft__model__artifact.py.md @@ -0,0 +1,116 @@ +# Einheiten: backend/tft/model/artifact.py +stand: f41ac76b5789 + +## backend/tft/model/artifact.py::craftable_items +beschreibung: Filtert aus den statischen Items die standardmäßig kombinierbaren Items heraus. +input: `static_items: dict` — vom Static-Datensatz abgeleitete Item-Definitionen +output: `list[str]` alphabetisch sortierter API-Namen kombinierbarer Items +entscheidungen: +- Spatula- und FryingPan-Komponenten schließen jedes Item mit einer Emblem-Komponente aus + beleg: "not set(comp) & SPATULA_COMPONENTS" +- Nur Items mit genau zwei Komponenten und gültigem API-Namenpräfix gelten als kombinierbar + beleg: "api.startswith(\"TFT_Item_\")" +- Das Ergebnis wird alphabetisch sortiert zurückgegeben + beleg: "return sorted(pool)" +kanten: +- ruft-auf: backend/tft/model/artifact.py::recipes +- ruft-auf: backend/tft/model/artifact.py::component_pool +- wird-genutzt-von: backend/tft/model/artifact.py::build + +## backend/tft/model/artifact.py::recipes +beschreibung: Bildet die kombinierbaren Items auf einen sortierten Komponenten-Schlüssel ab. +input: `static_items: dict` — Item-Definitionen aus dem Static-Datensatz +output: `dict` mit `"CompA|CompB"`-Schlüssel und Item-API als Wert +entscheidungen: +- Der Komponenten-Schlüssel wird aus der sortierten Komponentenliste gebildet + beleg: "\"|\".join(sorted(static_items[api][\"composition\"]))" +- Nur kombinierbare Items fließen über `craftable_items` ein + beleg: "for api in craftable_items(static_items)" +kanten: +- ruft-auf: backend/tft/model/artifact.py::craftable_items +- wird-genutzt-von: backend/tft/model/artifact.py::build + +## backend/tft/model/artifact.py::component_pool +beschreibung: Bestimmt den Pool an Basis-Komponenten, die häufig in Rezepten vorkommen. +input: `static_items: dict` — Item-Definitionen aus dem Static-Datensatz +output: `list[str]` sortierte Komponenten-API-Namen, die in mindestens drei Rezepten vorkommen +entscheidungen: +- Eine Komponente muss in mindestens drei Rezepten vorkommen, um in den Pool zu gelangen + beleg: "if n >= 3" +- Nur Komponenten aus kombinierbaren Items werden gezählt + beleg: "for api in craftable_items(static_items):" +kanten: +- ruft-auf: backend/tft/model/artifact.py::craftable_items +- wird-genutzt-von: backend/tft/model/artifact.py::build + +## backend/tft/model/artifact.py::spell_dps_cap +beschreibung: Berechnet das 90. Perzentil der Spell-DPS aller Units auf 1 Stern ohne Items als Deckelwert. +input: `static: dict`, `roles: dict` — Static-Datensatz und Rollen-Klassifikation pro Unit +output: `float | None` mit dem 90.-Perzentil-Wert oder `None`, wenn keine Werte existieren +entscheidungen: +- Nur Spell-DPS-Werte größer als null gehen in die Berechnung ein + beleg: "if (p := statsheet.unit_stats(" +- Bei fehlenden Werten wird `None` statt eines Default-Werts zurückgegeben + beleg: "return None" +- Die Auswahl erfolgt über das 90. Perzentil als relativer Spell-Deckel + beleg: "return values[int(len(values) * 0.9)]" +kanten: +- nutzt: backend/tft/model/statsheet.py::unit_stats +- wird-genutzt-von: backend/tft/model/artifact.py::build + +## backend/tft/model/artifact.py::_augment_specs +beschreibung: Delegiert die Erzeugung der Augment-Spezifikationen an das Augment-Modul. +input: `static: dict` — Static-Datensatz mit Augment- und Unit-Definitionen +output: `dict` mit den aufgebauten Augment-Spezifikationen +entscheidungen: +- Die Augment- und Unit-Sektion des Static-Datensatzes wird an den Builder weitergereicht + beleg: "return build_specs(static[\"augments\"], static[\"units\"])" +kanten: +- ruft-auf: backend/tft/model/augments.py::build_specs +- wird-genutzt-von: backend/tft/model/artifact.py::build + +## backend/tft/model/artifact.py::build +beschreibung: Baut das versionierte Analyse-Artefakt als Vertrag zwischen Pipeline und Simulation. +input: `static: dict`, `learned: dict | None`, `extra_meta: dict | None` — Roh-Daten plus optionale Ergänzungen +output: `dict` mit Meta-, Static-, Rollen-, Item- und Augment-Abschnitten des Artefakts +entscheidungen: +- Die Schemaversion wird als Top-Level-Konstante fixiert + beleg: "\"schema_version\": SCHEMA_VERSION," +- Rollen werden über `baseline.classify_role` je Unit bestimmt + beleg: "api: baseline.classify_role(unit)" +- Ein leerer `augment_pool` signalisiert dem Sim die Nutzung aller geparsten Augments + beleg: "\"augment_pool\": []," +kanten: +- ruft-auf: backend/tft/model/artifact.py::spell_dps_cap +- ruft-auf: backend/tft/model/artifact.py::craftable_items +- ruft-auf: backend/tft/model/artifact.py::component_pool +- ruft-auf: backend/tft/model/artifact.py::recipes +- ruft-auf: backend/tft/model/artifact.py::_augment_specs +- nutzt: backend/tft/model/baseline.py::classify_role + +## backend/tft/model/artifact.py::save +beschreibung: Schreibt das gebaute Artefakt als JSON in das versionsspezifische Verzeichnis und aktualisiert den Latest-Pointer. +input: `artifact: dict` — das von `build` erzeugte Artefakt +output: `str` mit dem Pfad zur geschriebenen `analysis.json` +entscheidungen: +- Zielverzeichnis wird über Set-Nummer und Baudatum aus `paths.artifact_dir` bestimmt + beleg: "out_dir = paths.artifact_dir(set_number, build_date)" +- Das Verzeichnis wird inklusive Eltern bei Bedarf angelegt + beleg: "out_dir.mkdir(parents=True, exist_ok=True)" +- Der Latest-Pointer wird separat als kleine JSON-Datei geschrieben + beleg: "json.dumps({\"path\": str(out_path)})" +kanten: +- nutzt: backend/tft/paths.py::artifact_dir +- nutzt: backend/tft/paths.py::latest_artifact_pointer + +## backend/tft/model/artifact.py::load +beschreibung: Lädt das aktuellste Artefakt für ein gegebenes Set über den Latest-Pointer. +input: `set_number: int` — die TFT-Set-Nummer +output: `dict` mit dem deserialisierten Artefakt aus der referenzierten `analysis.json` +entscheidungen: +- Ohne vorhandenen Pointer bricht der Aufruf mit einer Laufzeit-Anweisung ab + beleg: "raise SystemExit(" +- Der tatsächliche Pfad wird aus dem Pointer-JSON gelesen, nicht aus dem Dateinamen abgeleitet + beleg: "path = json.loads(pointer.read_text())[\"path\"]" +kanten: +- nutzt: backend/tft/paths.py::latest_artifact_pointer diff --git a/.planer/einheiten/backend__tft__model__augments.py.md b/.planer/einheiten/backend__tft__model__augments.py.md new file mode 100644 index 0000000..34f2b6f --- /dev/null +++ b/.planer/einheiten/backend__tft__model__augments.py.md @@ -0,0 +1,46 @@ +# Einheiten: backend/tft/model/augments.py +stand: f41ac76b5789 + +## backend/tft/model/augments.py::_num +beschreibung: Liest einen numerischen Effekt-Wert aus einem Dict über eine Liste bevorzugter Schlüssel. +input: ein `effects`-Dict und ein Tupel von Schlüsselnamen +output: den ersten positiven Zahlenwert oder `None`, falls keiner passt +entscheidungen: +- Nur der erste positive Treffer zählt; Null und negative Werte werden übersprungen. + beleg: "if isinstance(val, (int, float)) and val > 0:" +- Es wird strikt zwischen echten Zahlen und Nicht-Zahlen gefiltert, bevor der Wert gilt. + beleg: "if isinstance(val, (int, float)) and val > 0:" +- Fehlt jeder passende Schlüssel, signalisiert die Funktion das durch `None`. + beleg: "return None" +kanten: +- wird-genutzt-von: backend/tft/model/augments.py::build_spec + +## backend/tft/model/augments.py::build_spec +beschreibung: Zerlegt ein einzelnes Augment in mechanisch anwendbare Atome aus Beschreibung und Effects-Block. +input: ein Augment-Dict (`api`, `desc`, `effects`) und das `static_units`-Verzeichnis +output: ein Dict mit `atoms`-Liste und `offerable`-Flag +entscheidungen: +- Ein Augment gilt nur dann als „offerable", wenn die Beschreibung lang genug und frei von Platzhaltern ist. + beleg: "offerable = len(desc) >= 10 and \"_Desc\" not in desc and \"@\" not in desc" +- Beim Spieler-HP-Atom wird der Effects-Wert bevorzugt, sonst der Regex-Treffer aus der Beschreibung. + beleg: "\"amount\": int(_num(effects, PLAYER_HP_KEYS) or m.group(1))" +- Team-Stat-Buffs werden nur übernommen, wenn die Beschreibung unkonditional ist. + beleg: "if TEAM_RE.search(desc) and not CONDITIONAL_RE.search(desc):" +- Fraktionswerte werden zur Vergleichbarkeit mit `statsheet` über `_fraction` normalisiert. + beleg: "value = _fraction(val)" +kanten: +- ruft-auf: backend/tft/model/augments.py::_num +- nutzt: backend/tft/model/statsheet.py::_fraction +- wird-genutzt-von: backend/tft/model/augments.py::build_specs + +## backend/tft/model/augments.py::build_specs +beschreibung: Wendet `build_spec` auf alle Augmente eines Katalogs an und indiziert das Ergebnis nach API. +input: ein `static_augments`-Dict und das `static_units`-Verzeichnis +output: ein Dict, das jeder API ihr `build_spec`-Ergebnis zuordnet +entscheidungen: +- Die Funktion ist eine reine Dictionary-Komprehension über den gesamten Katalog. + beleg: "return {api: build_spec(aug, static_units) for api, aug in static_augments.items()}" +- Die Schlüssel des Eingabe-Dicts werden unverändert als API-Schlüssel übernommen. + beleg: "for api, aug in static_augments.items()" +kanten: +- ruft-auf: backend/tft/model/augments.py::build_spec diff --git a/.planer/einheiten/backend__tft__model__baseline.py.md b/.planer/einheiten/backend__tft__model__baseline.py.md new file mode 100644 index 0000000..1e13615 --- /dev/null +++ b/.planer/einheiten/backend__tft__model__baseline.py.md @@ -0,0 +1,32 @@ +# Einheiten: backend/tft/model/baseline.py +stand: f41ac76b5789 + +## backend/tft/model/baseline.py::unit_value +beschreibung: Berechnet einen numerischen Einheitenwert aus Einkaufskosten und Stern-Aufstufung. +input: cost (int) und stars (int) +output: float-Wert gemäß cost · 3^(stars−1) +entscheidungen: +- Höhere Sternstufen werden exponentiell gewichtet, nicht additiv. + beleg: "cost * 3 ** (stars - 1)" +- Die Berechnung ist rein deterministisch ohne Seiteneffekte. + beleg: "def unit_value(cost: int, stars: int) -> float:" +- Das Ergebnis skaliert mit den Kosten der Einheit. + beleg: "return cost * 3 ** (stars - 1)" +kanten: +- wird-genutzt-von: backend/tft/model/baseline.py::classify_role + +## backend/tft/model/baseline.py::classify_role +beschreibung: Ordnet eine Einheit über ihr Rollenattribut (mit Statistik-Fallback) einer strategischen Rolle zu. +input: unit (dict) mit mindestens den Schlüsseln "role" und "stats" +output: eine der vier Rollenstrings frontline | ad_carry | ap_carry | utility +entscheidungen: +- Fehlende Rollen werden über das letzte Element zu frontline bzw. ad_carry aufgelöst. + beleg: "return \"frontline\" if stats[\"range\"] <= 1 else \"ad_carry\"" +- Treffererkennung erfolgt robust auf Teilstrings statt auf exakter Gleichheit. + beleg: "if \"tank\" in role:" +- Magische/AD-Rollen werden gegenüber dem Stat-Fallback bevorzugt geprüft. + beleg: "if \"caster\" in role or \"ap\" in role:" +- Die Normalisierung der Rolle erfolgt via lower() auf einen Default-leeren String. + beleg: "(unit.get(\"role\") or \"\").lower()" +kanten: +- wird-genutzt-von: backend/tft/model/baseline.py::unit_value diff --git a/.planer/einheiten/backend__tft__model__calibrate.py.md b/.planer/einheiten/backend__tft__model__calibrate.py.md new file mode 100644 index 0000000..3061449 --- /dev/null +++ b/.planer/einheiten/backend__tft__model__calibrate.py.md @@ -0,0 +1,69 @@ +# Einheiten: backend/tft/model/calibrate.py +stand: f41ac76b5789 + +## backend/tft/model/calibrate.py::_ranks +beschreibung: Wandelt eine Liste von Werten in mittlere Ränge um und behandelt dabei gleiche Werte (Ties) korrekt. +input: Eine Liste von Float-Werten. +output: Eine Liste von Float-Rängen gleicher Länge mit 1-basierter Mittelrang-Vergabe bei Ties. +entscheidungen: +- Gleiche Werte erhalten alle den Mittelrang ihrer Position. + beleg: "midrank = (i + j) / 2 + 1" +- Die Ränge sind 1-basiert. + beleg: "ranks[order[k]] = midrank" +- Iteration erfolgt über sortierte Indizes statt über die Werte direkt. + beleg: "order = sorted(range(len(values)), key=lambda i: values[i])" +kanten: +- wird-genutzt-von: backend/tft/model/calibrate.py::spearman + +## backend/tft/model/calibrate.py::spearman +beschreibung: Berechnet den Spearman-Rangkorrelationskoeffizienten zwischen zwei gleichlangen Wertelisten. +input: Zwei gleich lange Listen von Float-Werten. +output: Der Spearman-Korrelationskoeffizient als Float, bei verschwindender Varianz 0.0. +entscheidungen: +- Division durch Null wird explizit abgefangen und mit 0.0 beantwortet. + beleg: "if va == 0 or vb == 0:" +- Die Kovarianz und Varianzen werden als Populationsgrößen berechnet. + beleg: "va = sum((x - ma) ** 2 for x in ra) ** 0.5" +kanten: +- ruft-auf: backend/tft/model/calibrate.py::_ranks +- wird-genutzt-von: backend/tft/model/calibrate.py::calibrate + +## backend/tft/model/calibrate.py::board_from_row +beschreibung: Deserialisiert die aus der DB kommenden JSON-Strings für Units und Augments in Board-Strukturen. +input: Zwei JSON-Strings (Units und Augments). +output: Ein Tupel aus einer Liste von Unit-Dicts und einer Liste von Augment-Namen. +entscheidungen: +- Das Feld für Items heißt tolerant „itemNames" und wird mit Default-Leerliste gelesen. + beleg: "\"items\": u.get(\"itemNames\", [])," +- Die Tier-Information der Unit wird als Sternzahl interpretiert. + beleg: "\"stars\": u[\"tier\"]," +kanten: +- wird-genutzt-von: backend/tft/model/calibrate.py::_holdout_scores + +## backend/tft/model/calibrate.py::_holdout_scores +beschreibung: Lädt Endboard-Zeilen aus der DB, baut daraus Boards je Match und lässt sie durch score_fn bewerten. +input: Eine DB-Connection, ein Artifact-Dict, ein Holdout-Flag und eine score-Funktion. +output: Ein Dict, das je match_id eine Liste von (placement, score)-Paaren enthält. +entscheidungen: +- Die Holdout-Filterung erfolgt match-bezogen über das letzte Zeichen der match_id. + beleg: "where += \" AND substr(match_id, -1) IN ('0', '5')\"" +- Das Set wird aus den Artifact-Metadaten als Filterparameter gelesen. + beleg: "set_number = artifact[\"meta\"][\"set\"]" +kanten: +- ruft-auf: backend/tft/model/calibrate.py::board_from_row +- wird-genutzt-von: backend/tft/model/calibrate.py::calibrate + +## backend/tft/model/calibrate.py::calibrate +beschreibung: Ermittelt die mittlere Spearman-Korrelation zwischen Board-Score und Platzierung über Holdout-Matches als Kalibrierungsmetrik. +input: Eine DB-Connection, ein Artifact-Dict, optional ein Holdout-Flag und eine score-Funktion. +output: Ein Dict mit „matches" (Anzahl) und „mean_spearman" (Mittelwert der Korrelationen). +entscheidungen: +- Nur Matches mit vollständigen 8 Spielern fließen in die Korrelation ein. + beleg: "if len(players) < 8:" +- Die Korrelation wird negiert, da niedrigere Platzierungen besser sind. + beleg: "correlations.append(-spearman(placements, scores))" +- Ohne Korrelationen wird 0.0 als Mittelwert zurückgegeben. + beleg: "\"mean_spearman\": sum(correlations) / n if n else 0.0," +kanten: +- ruft-auf: backend/tft/model/calibrate.py::_holdout_scores +- ruft-auf: backend/tft/model/calibrate.py::spearman diff --git a/.planer/einheiten/backend__tft__model__learn.py.md b/.planer/einheiten/backend__tft__model__learn.py.md new file mode 100644 index 0000000..7ee2df2 --- /dev/null +++ b/.planer/einheiten/backend__tft__model__learn.py.md @@ -0,0 +1,46 @@ +# Einheiten: backend/tft/model/learn.py +stand: f41ac76b5789 + +## backend/tft/model/learn.py::_multiplier +beschreibung: Berechnet einen um Bayessche Schrumpfung zentrierten Stärke-Multiplikator aus einer Liste von Platzierungen. +input: eine Liste von Platzierungswerten (Integers) +output: ein Float-Multiplikator in [0.8, 1.25], der bei 1.0 bei mittlerer Platzierung liegt +entscheidungen: +- Schrupfung gegen das Schnitt-Niveau (MEAN_PLACEMENT) erfolgt via PSEUDO_COUNT Pseudo-Beobachtungen. + beleg: "(sum(placements) + MEAN_PLACEMENT * PSEUDO_COUNT) / (n + PSEUDO_COUNT)" +- Pro Platzierungs-Punkt Abweichung vom Mittelwert wird DELTA_SCALE = 0.06 aufaddiert. + beleg: "mult = 1.0 + (MEAN_PLACEMENT - mean) * DELTA_SCALE" +- Der Ergebniswert wird auf das Intervall MULT_CAP = (0.8, 1.25) gekappt. + beleg: "return min(max(mult, MULT_CAP[0]), MULT_CAP[1])" +kanten: +- wird-genutzt-von: backend/tft/model/learn.py::learn + +## backend/tft/model/learn.py::learn +beschreibung: Aggregiert Endboard-Zeilen zu placementsbezogenen Multiplikatoren für Einheiten, Traits, Items, Augments und Paare. +input: eine Liste von Tupeln (placement, units_json, traits_json, augments_json) +output: ein Dict mit Multiplikatoren je Kategorie, signifikanten Paar-Lifts sowie Brett-Zähler +entscheidungen: +- Top-4-Paarungen werden nur gezählt, wenn die Beobachtungszahl PAIR_MIN_N erreicht und ein Erwartungswert > 0 vorliegt. + beleg: "if n_ab < PAIR_MIN_N:" +- Nur aktive Traits (tier_current > 0) gehen in die Trait-Aggregation ein. + beleg: "if t.get(\"tier_current\", 0) > 0:" +- Paar-Lifts werden auf PAIR_LIFT_CAP gekappt und auf 4 Nachkommastellen gerundet zurückgegeben. + beleg: "lift = min(max(lift, PAIR_LIFT_CAP[0]), PAIR_LIFT_CAP[1])" +- Alle vier Kategorie-Multiplikatoren werden ebenfalls auf 4 Nachkommastellen gerundet. + beleg: "{k: round(_multiplier(v), 4) for k, v in unit_placements.items()}," +kanten: +- ruft-auf: backend/tft/model/learn.py::_multiplier +- wird-genutzt-von: backend/tft/model/learn.py::learn_from_db + +## backend/tft/model/learn.py::learn_from_db +beschreibung: Liest Endboard-Zeilen eines Sets aus einer SQL-Verbindung und gibt das gelernte Multiplikator-Dict zurück. +input: eine SQL-Connection, eine Set-Nummer und ein Flag zum Ausschluss von Holdout-Matches +output: das von learn() erzeugte Multiplikator-Dict +entscheidungen: +- Bei exclude_holdout werden Matches ausgefiltert, deren match_id auf '0' oder '5' endet. + beleg: "where += \" AND substr(match_id, -1) NOT IN ('0', '5')\"" +- Die Selektion projiziert nur die für learn() nötigen Spalten. + beleg: "\"SELECT placement, units, traits, augments FROM endboards {where}\"" +kanten: +- ruft-auf: backend/tft/model/learn.py::learn +- nutzt: backend/tft/model/learn.py::learn diff --git a/.planer/einheiten/backend__tft__model__score.py.md b/.planer/einheiten/backend__tft__model__score.py.md new file mode 100644 index 0000000..703fb72 --- /dev/null +++ b/.planer/einheiten/backend__tft__model__score.py.md @@ -0,0 +1,57 @@ +# Einheiten: backend/tft/model/score.py +stand: f41ac76b5789 + +## backend/tft/model/score.py::active_trait_tiers +beschreibung: Ermittelt zu jedem Trait einer Board-Aufstellung den erreichten Breakpoint und gibt nur die aktiven Stufen zurück. +input: board_units (Liste mit Unit-Dicts), static_traits (Trait-Definitionen), static_units (Unit-Definitionen) +output: dict trait_api_name → erreichte Breakpoint-Ordnung (1-basiert), nur aktive Traits +entscheidungen: +- Nur Traits mit einem Treffer in `static_traits` werden berücksichtigt, Unbekannte stillschweigend übersprungen. + beleg: "if not info:" +- Der höchste Breakpoint, dessen `min_units` erreicht ist, gewinnt — Iteration über alle Breakpoints mit laufender Aktualisierung. + beleg: "for i, bp in enumerate(info[\"breakpoints\"], start=1):" +- Traits, die keinen Breakpoint erreichen, erscheinen gar nicht im Ergebnis (`ordinal == 0`). + beleg: "if ordinal:" +kanten: +- wird-genutzt-von: backend/tft/model/score.py::score_board + +## backend/tft/model/score.py::unit_profile +beschreibung: Baut das Kampfprofil einer einzelnen Board-Unit aus DPS, eHP, Defensive-Flag und Unit-Bezug. +input: u (Board-Unit-Dict), artifact (gesamtes Artifact mit static-Daten, Rollen, Spell-DPS-Cap) +output: dict mit "off", "def", "defensive", "unit" oder None bei unbekannter Unit +entscheidungen: +- Unbekannte Units (fehlend in `static.units`) führen zu `None`, statt einen Fehler zu werfen. + beleg: "if not unit:" +- Rollen werden bevorzugt aus dem Artifact gelesen, sonst via `baseline.classify_role` bestimmt. + beleg: "artifact.get(\"roles\", {}).get(u[\"api_name\"]) or baseline.classify_role(unit)" +- Defensive-Status gilt ausschließlich für Rollen "frontline" und "utility" — Cast-Rollen steuern nur die Zielreihenfolge. + beleg: "\"defensive\": role in (\"frontline\", \"utility\")" +kanten: +- ruft-auf: backend/tft/model/baseline.py::classify_role +- nutzt: backend/tft/model/statsheet.py::unit_stats +- wird-genutzt-von: backend/tft/model/score.py::board_profiles + +## backend/tft/model/score.py::board_profiles +beschreibung: Liefert die Kampfprofile aller Units eines Boards, wobei Units ohne Profil herausgefiltert werden. +input: board_units (Liste mit Unit-Dicts), artifact (gesamtes Artifact) +output: Liste der Profile (None-Einträge aus `unit_profile` werden übersprungen) +entscheidungen: +- Die Filterung erfolgt inline per walrus-Operator, sodass kein zweiter Durchlauf nötig ist. + beleg: "if (p := unit_profile(u, artifact))" +kanten: +- ruft-auf: backend/tft/model/score.py::unit_profile +- wird-genutzt-von: backend/tft/model/score.py::score_board + +## backend/tft/model/score.py::score_board +beschreibung: Berechnet den Board-Score als Quadratwurzel aus (Σoff · Σdef), skaliert durch 100. +input: board_units (Liste mit Unit-Dicts), augments (Signatur-Kompatibilität, ungenutzt), artifact (gesamtes Artifact) +output: float — der Board-Score für Vergleiche +entscheidungen: +- Die Formel ist `sqrt(Σoff × Σdef)`; sie kombiniert Offensiv- und Defensiv-Summen multiplikativ unter einer Wurzel. + beleg: "return (off * dfn) ** 0.5 / 100" +- Augments bleiben bewusst ungenutzt, nur die Signatur bleibt für Aufrufer kompatibel. + beleg: "augments bleibt nur für Signatur-Kompatibilität." +- Die Division durch 100 dient nur der Anzeige-Skalierung und ist für Vergleiche irrelevant. + beleg: "/100: nur Anzeige-Skalierung, für Vergleiche irrelevant." +kanten: +- ruft-auf: backend/tft/model/score.py::board_profiles diff --git a/.planer/einheiten/backend__tft__model__statsheet.py.md b/.planer/einheiten/backend__tft__model__statsheet.py.md new file mode 100644 index 0000000..1bceb94 --- /dev/null +++ b/.planer/einheiten/backend__tft__model__statsheet.py.md @@ -0,0 +1,95 @@ +# Einheiten: backend/tft/model/statsheet.py +stand: f41ac76b5789 + +## backend/tft/model/statsheet.py::_empty_acc +beschreibung: Liefert ein frisches Sammel-Dictionary mit allen bekannten Stat-Buff-Akkumulatoren auf 0.0 initialisiert. +input: keine Argumente +output: ein `dict` mit 12 numerischen Stat-Feldern (z. B. `ad_pct`, `ap_flat`, `hp_pct`). +entscheidungen: +- Alle Felder werden explizit auf 0.0 gesetzt, damit Akkumulatoren ohne Vorbelastung starten. + beleg: "{\"ad_pct\": 0.0, \"ap_flat\": 0.0, \"as_pct\": 0.0, \"hp_flat\": 0.0," +- Es wird genau ein gemeinsames Akkumulator-Schema für Items und Trait-Buffs verwendet. + beleg: "return {\"ad_pct\": 0.0, \"ap_flat\": 0.0, \"as_pct\": 0.0, \"hp_flat\": 0.0," +kanten: +- wird-genutzt-von: backend/tft/model/statsheet.py::_apply_item_effects +- wird-genutzt-von: backend/tft/model/statsheet.py::trait_buffs +- wird-genutzt-von: backend/tft/model/statsheet.py::unit_stats + +## backend/tft/model/statsheet.py::_apply_item_effects +beschreibung: Überträgt die modellierten Item-Effekte aus dem Datenkatalog in den Stat-Akkumulator und normalisiert Werteinheiten. +input: `item_apis` (zu applizierende Item-IDs), `static_items` (Item-Katalog), `acc` (Stat-Akkumulator) +output: keine Rückgabe; `acc` wird in-place um Item-Boni angereichert. +entscheidungen: +- Unbekannte oder `None`-Effekte werden bewusst ignoriert, um bespoke Mechanik nicht zu modellieren. + beleg: "if val is None:" +- Roh-Listenwerte werden via `_fraction` normalisiert, während flache Skalierungen direkt addiert werden. + beleg: "elif key in (\"BonusDamage\", \"DamageAmp\"):" +- Prozent-Keys (`AS`, `CritChance`) werden durch 100 geteilt, weil sie als Prozentzahl geliefert werden. + beleg: "elif key == \"AS\":" +- `MAPPED_ITEM_KEYS` definiert die Menge der modellierten Effekte als vertragliche Whitelist. + beleg: "MAPPED_ITEM_KEYS = (\"AD\", \"AP\", \"AS\", \"CritChance\", \"Health\", \"Armor\"," +kanten: +- nutzt: backend/tft/model/statsheet.py::_fraction +- wird-genutzt-von: backend/tft/model/statsheet.py::unit_stats + +## backend/tft/model/statsheet.py::item_is_modeled +beschreibung: Entscheidet anhand der modellierten Effekt-Whitelist, ob ein Item überhaupt in die Berechnung einfließt. +input: `item_info` (Item-Datensatz mit `effects`) +output: `bool`, `True` sobald mindestens ein modellierter Key einen Nicht-`None`-Wert hat. +entscheidungen: +- Nur Werte != None zählen, da `None` als „nicht angegeben" interpretiert wird. + beleg: "return any(effects.get(k) is not None for k in MAPPED_ITEM_KEYS)" +- Die modellierten Keys sind als Modul-Konstante `MAPPED_ITEM_KEYS` zusammengefasst. + beleg: "MAPPED_ITEM_KEYS = (\"AD\", \"AP\", \"AS\", \"CritChance\", \"Health\", \"Armor\"," +kanten: +- nutzt: backend/tft/model/statsheet.py::_apply_item_effects + +## backend/tft/model/statsheet.py::_fraction +beschreibung: Normalisiert cdragon-Werte, die mal als Fraction (0.15) und mal als Prozentzahl (15.0) vorliegen, auf einen einheitlichen Bruch. +input: `value` (float) +output: `float` im Bereich [0, 1] (Fraction). +entscheidungen: +- Schwellwert 1.0 entscheidet, ob ein Wert als bereits normalisierte Fraction gilt. + beleg: "return value if abs(value) <= 1.0 else value / 100" +- Beträge werden verglichen, damit auch negative Fraktionen korrekt behandelt werden. + beleg: "return value if abs(value) <= 1.0 else value / 100" +kanten: +- wird-genutzt-von: backend/tft/model/statsheet.py::_apply_item_effects +- wird-genutzt-von: backend/tft/model/statsheet.py::trait_buffs + +## backend/tft/model/statsheet.py::trait_buffs +beschreibung: Zerlegt Trait-Variablennamen in Tokens und leitet daraus teamweite Stat-Buffs sowie die Liste der erkannten Traits ab. +input: `active_tiers` (aktive Trait-Breakpoints), `static_traits` (Trait-Katalog) +output: Tupel `(buffs, recognized)` mit Akkumulator-Dictionary und Set erkannter Trait-IDs. +entscheidungen: +- Mechanik-Parameter werden über eine Token-Blacklist früh aussortiert. + beleg: "if tokens & _SKIP_TOKENS or name.startswith(\"{\"):" +- `ap`-Tokens werden mit `* 100` auf den flachen AP-Konventionen aufsummiert. + beleg: "elif \"ap\" in tokens:" +- Variablen ohne numerischen Wert werden übersprungen, um Token-Mismatches zu vermeiden. + beleg: "if value is None or not isinstance(value, (int, float)):" +- Nur Traits, bei denen mindestens eine Variable als Buff gemappt wurde, landen in `recognized`. + beleg: "if matched:" +kanten: +- nutzt: backend/tft/model/statsheet.py::_empty_acc +- nutzt: backend/tft/model/statsheet.py::_fraction +- nutzt: backend/tft/model/statsheet.py::_TOKEN_RE + +## backend/tft/model/statsheet.py::unit_stats +beschreibung: Berechnet das effektive Kampfprofil (eHP, Gesamt-DPS, Spell-DPS) einer Unit aus Basiswerten, Sternenmultiplikatoren, Team-Buffs und Items. +input: `unit` (Champion-Daten), `stars`, `item_apis`, `static_items`, `team_buffs`, `role`, optional `spell_cap` +output: `dict` mit den Schlüsseln `ehp`, `dps` und `spell_dps`. +entscheidungen: +- HP und AD skalieren über `HP_STAR_MULT` bzw. `AD_STAR_MULT` je nach Sternenlevel. + beleg: "hp = (stats.get(\"hp\") or 0) * HP_STAR_MULT ** (stars - 1)" +- eHP kombiniert Rüstung/MR und Schadensreduktion multiplikativ. + beleg: "ehp = hp * (1 + (armor + mr) / 200) * (1 + acc[\"dr\"])" +- Die Cast-Rate wird durch `CAST_RATE_CAP` gedeckelt, um Ausreißer zu verhindern. + beleg: "CAST_RATE_CAP," +- Spell-DPS ist auf `SPELL_AUTO_CAP` mal eigene Auto-DPS begrenzt, mit optionalem Floor. + beleg: "limit = SPELL_AUTO_CAP * auto_dps" +- Team-Buffs und Item-Effekte werden in `_empty_acc` zusammengeführt, um Bonus-Stats konsistent anzureichern. + beleg: "acc = _empty_acc()" +kanten: +- nutzt: backend/tft/model/statsheet.py::_empty_acc +- nutzt: backend/tft/model/statsheet.py::_apply_item_effects diff --git a/.planer/einheiten/backend__tft__paths.py.md b/.planer/einheiten/backend__tft__paths.py.md new file mode 100644 index 0000000..24eb40c --- /dev/null +++ b/.planer/einheiten/backend__tft__paths.py.md @@ -0,0 +1,50 @@ +# Einheiten: backend/tft/paths.py +stand: f41ac76b5789 + +## backend/tft/paths.py::static_dir +beschreibung: Liefert den Pfad zum Verzeichnis der statischen Daten für ein bestimmtes Set und einen Patch. +input: `set_number: int`, `patch: str` +output: Pfad zu `STATIC / f"set{set_number}_{patch}"`. +entscheidungen: +- Der Verzeichnisname kombiniert Set-Nummer und Patch durch einen Unterstrich. + beleg: "return STATIC / f\"set{set_number}_{patch}\"" +- Der Pfad wird relativ zum vordefinierten STATIC-Wurzelverzeichnis gebildet. + beleg: "STATIC = DATA / \"static\"" +kanten: +- nutzt: backend/tft/paths.py::STATIC + +## backend/tft/paths.py::latest_static_pointer +beschreibung: Liefert den Pfad zur Pointer-Datei, die auf den aktuellsten statischen Datenstand verweist. +input: keine Parameter +output: Pfad zu `STATIC / "latest.json"`. +entscheidungen: +- Der aktuellste Datenstand wird über eine zentrale `latest.json`-Datei markiert. + beleg: "return STATIC / \"latest.json\"" +- Die Funktion kapselt den Pfad zur Latest-Pointer-Datei als eigene Einheit. + beleg: "def latest_static_pointer() -> Path:" +kanten: +- nutzt: backend/tft/paths.py::STATIC + +## backend/tft/paths.py::artifact_dir +beschreibung: Liefert den Pfad zum Artefakt-Verzeichnis eines Sets zu einem bestimmten Build-Datum. +input: `set_number: int`, `build_date: str` +output: Pfad zu `ARTIFACTS / f"set{set_number}" / build_date`. +entscheidungen: +- Artefakte werden zweistufig organisiert: erst nach Set-Nummer, dann nach Build-Datum. + beleg: "return ARTIFACTS / f\"set{set_number}\" / build_date" +- Der Pfad wird relativ zum vordefinierten ARTIFACTS-Wurzelverzeichnis gebildet. + beleg: "ARTIFACTS = DATA / \"artifacts\"" +kanten: +- nutzt: backend/tft/paths.py::ARTIFACTS + +## backend/tft/paths.py::latest_artifact_pointer +beschreibung: Liefert den Pfad zur Pointer-Datei des aktuellsten Artefakt-Builds für ein bestimmtes Set. +input: `set_number: int` +output: Pfad zu `ARTIFACTS / f"set{set_number}" / "latest.json"`. +entscheidungen: +- Pro Set existiert eine eigene `latest.json` als Pointer auf den aktuellsten Build. + beleg: "return ARTIFACTS / f\"set{set_number}\" / \"latest.json\"" +- Der Set-spezifische Latest-Pointer liegt innerhalb des Set-Unterordners. + beleg: "ARTIFACTS / f\"set{set_number}\"" +kanten: +- nutzt: backend/tft/paths.py::ARTIFACTS diff --git a/.planer/einheiten/backend__tft__sim__autoplay.py.md b/.planer/einheiten/backend__tft__sim__autoplay.py.md new file mode 100644 index 0000000..9913c33 --- /dev/null +++ b/.planer/einheiten/backend__tft__sim__autoplay.py.md @@ -0,0 +1,45 @@ +# Einheiten: backend/tft/sim/autoplay.py +stand: f41ac76b5789 + +## backend/tft/sim/autoplay.py::play_afk +beschreibung: Spielt ein vollständiges Game headless zu Ende, ohne steuernd einzugreifen. +input: ein laufendes `Game`-Objekt. +output: die finale Platzierung (`game.placement`) als int. +entscheidungen: +- Kein eigener Steuerungscode — jede Runde wird nur durch `game.step()` fortgeschaltet. + beleg: "game.step()" +- Abbruchbedingung ist ausschließlich das Ende des Spiels über `game.over`. + beleg: "while not game.over:" +kanten: +- nutzt: backend/tft/sim/game.py::Game +- wird-genutzt-von: backend/tft/sim/autoplay.py::run + +## backend/tft/sim/autoplay.py::play_econ +beschreibung: Spielt ein Game mit der Econ-Policy (`fast8`) zu Ende und liefert die Platzierung. +input: ein laufendes `Game`-Objekt. +output: die finale Platzierung (`game.placement`) als int. +entscheidungen: +- Verwendet fest das Archetyp-Profil `fast8` aus der Policy-Bibliothek. + beleg: "params = policy.ARCHETYPES[\"fast8\"]" +- Greift pro Runde sowohl über die Policy als auch über `game.step()` in den Spielablauf ein. + beleg: "policy.act(game.player, game.pool, game.artifact, game.cfg, game.rng," +kanten: +- nutzt: backend/tft/sim/policy.py::act +- nutzt: backend/tft/sim/game.py::Game +- wird-genutzt-von: backend/tft/sim/autoplay.py::run + +## backend/tft/sim/autoplay.py::run +beschreibung: Führt n headless Spiele mit der gewählten Policy aus und aggregiert Platzierungen. +input: `artifact`, `cfg`, ein `policy_name` und die Anzahl `n` (optional `seed`). +output: ein Dict mit `games`, `avg_placement` und `top4_rate`. +entscheidungen: +- Die Auswahl der konkreten Strategie erfolgt über das Mapping `POLICIES` per Name. + beleg: "POLICIES[policy_name](game)" +- Pro Lauf wird ein neuer Seed aus `seed + i` abgeleitet, um Wiederholbarkeit pro Spiel zu garantieren. + beleg: "Game(artifact, cfg, seed=seed + i)" +- `top4_rate` zählt Platzierungen ≤ 4 als Erfolg. + beleg: "if p <= 4" +kanten: +- ruft-auf: backend/tft/sim/autoplay.py::play_afk +- ruft-auf: backend/tft/sim/autoplay.py::play_econ +- nutzt: backend/tft/sim/game.py::Game diff --git a/.planer/einheiten/backend__tft__sim__combat.py.md b/.planer/einheiten/backend__tft__sim__combat.py.md new file mode 100644 index 0000000..5a4f720 --- /dev/null +++ b/.planer/einheiten/backend__tft__sim__combat.py.md @@ -0,0 +1,76 @@ +# Einheiten: backend/tft/sim/combat.py +stand: f41ac76b5789 + +## backend/tft/sim/combat.py::FightResult +beschreibung: Datenklasse, die das Ergebnis eines Kampfes zwischen zwei Teams festhält. +input: Siegerkennzeichen "a"/"b"/None, gespielte Frames und überlebende Units beider Seiten +output: Ein `FightResult`-Objekt mit `winner`, `frames`, `survivors_a` und `survivors_b` +entscheidungen: +- Das Ergebnis-Format schließt einen Unentschieden-Fall explizit als `None` mit ein. + beleg: "winner: str | None # \"a\" | \"b\" | None = Unentschieden" +- Überlebende werden seitenweise als Liste von Unit-Dicts geführt, nicht als Indizes. + beleg: "survivors_a: list[dict]" +- Die Anzahl der simulierten Frames wird als Ganzzahl gerundet (`ceil`). + beleg: "frames=math.ceil(elapsed * FPS)," +kanten: +- wird-genutzt-von: backend/tft/sim/combat.py::fight + +## backend/tft/sim/combat.py::_queue +beschreibung: Bestimmt die Bearbeitungsreihenfolge der Units eines Teams als defensive zuerst, intern zufällig gemischt. +input: Team-Liste von Unit-Dicts und ein `random.Random`-RNG +output: Neue Liste, in der defensive Units vor offensiven stehen, beide Gruppen intern gemischt +entscheidungen: +- Defensive Units werden grundsätzlich vor offensiven angegriffen. + beleg: "defensive = [p for p in team if p[\"defensive\"]]" +- Innerhalb jeder Gruppe wird die Reihenfolge per `rng.shuffle` zufällig gemischt. + beleg: "rng.shuffle(defensive)" +- Die Sortierung ist nicht stabil, da die ursprüngliche Reihenfolge überschrieben wird. + beleg: "return defensive + offensive" +kanten: +- nutzt: backend/tft/sim/combat.py::_suffix_off +- wird-genutzt-von: backend/tft/sim/combat.py::fight + +## backend/tft/sim/combat.py::_suffix_off +beschreibung: Berechnet die Suffix-Summe der Offensive-Werte einer Ziel-Queue. +input: Liste von Unit-Dicts mit `"off"`-Feld +output: Liste gleicher Länge + 1, in der `suffix[i]` die Gesamt-Offense ab Unit `i` ist +entscheidungen: +- Die Liste ist absichtlich um einen Eintrag länger, damit `suffix[len(queue)] = 0` als Sicherheitselement dient. + beleg: "suffix = [0.0] * (len(queue) + 1)" +- Die Summation läuft rückwärts, um die echte Suffix-Summe zu bilden. + beleg: "for i in range(len(queue) - 1, -1, -1):" +kanten: +- wird-genutzt-von: backend/tft/sim/combat.py::fight + +## backend/tft/sim/combat.py::fight +beschreibung: Simuliert einen deterministischen Kampf zweier Teams eventbasiert bis zum nächsten Tod oder Zeitlimit. +input: Zwei Team-Profil-Listen, ein `random.Random`-RNG und eine Config mit `combat.max_frames` +output: `FightResult` mit Sieger, Framezahl und Überlebenden beider Seiten +entscheidungen: +- Zwischen zwei Todesereignissen werden beide Offensen als konstant angenommen, `dt = min(ta, tb)`. + beleg: "dt = min(ta, tb)" +- Bei Überschreitung des Zeitlimits wird `dt` auf das verbleibende Restbudget begrenzt. + beleg: "dt = limit - elapsed" +- Überzähliger Schaden wird verlustfrei auf das nächste Ziel übertragen, indem die Rest-Defense weiter reduziert wird. + beleg: "rb -= oa * dt" +- Bei Zeitlimit ohne verbleibende Offense wird der Sieg nach größerer Rest-Defense entschieden. + beleg: "winner = \"a\" if rest_a > rest_b + EPS else \"b\" if rest_b > rest_a + EPS else None" +- Ein gleicher Kill-Frame kann nicht beide Seiten treffen, da `else`-Zweige getrennt ausgewertet werden. + beleg: "else:" +kanten: +- ruft-auf: backend/tft/sim/combat.py::_queue +- ruft-auf: backend/tft/sim/combat.py::_suffix_off +- nutzt: backend/tft/sim/combat.py::FightResult +- wird-genutzt-von: backend/tft/sim/combat.py::player_damage + +## backend/tft/sim/combat.py::player_damage +beschreibung: Berechnet den Schaden, den ein Spieler nach einer Runde abhängig von erreichter Stage und Überlebenden erhält. +input: Stage-Index (1-basiert), Liste der überlebenden Units und eine Config mit `damage` +output: Schadenswert als `int` +entscheidungen: +- Der Basis-Schaden wird stageweise aus `cfg["damage"]["stage_base"]` gelesen, bei Überschreitung wird der letzte Eintrag verwendet. + beleg: "min(stage - 1, len(d[\"stage_base\"]) - 1)" +- Pro überlebender Unit wird ein linearer Zusatzschaden addiert. + beleg: "return base + d[\"per_surviving_unit\"] * len(survivors)" +kanten: +- nutzt: backend/tft/sim/combat.py::fight diff --git a/.planer/einheiten/backend__tft__sim__economy.py.md b/.planer/einheiten/backend__tft__sim__economy.py.md new file mode 100644 index 0000000..a1cda83 --- /dev/null +++ b/.planer/einheiten/backend__tft__sim__economy.py.md @@ -0,0 +1,59 @@ +# Einheiten: backend/tft/sim/economy.py +stand: f41ac76b5789 + +## backend/tft/sim/economy.py::interest +beschreibung: Berechnet die passiven Zinserträge auf Basis des aktuellen Goldstands. +input: aktueller Goldstand `gold` und die Konfiguration `cfg` +output: gekappter Zinsbetrag gemäß Konfigurationsobergrenze +entscheidungen: +- Der Zins wird in 10er-Schritten gewährt und auf einen Maximalwert gedeckelt. + beleg: "min(gold // 10 * g[\"interest_per_10\"], g[\"interest_cap\"])" +kanten: +- wird-genutzt-von: backend/tft/sim/economy.py::round_income + +## backend/tft/sim/economy.py::streak_gold +beschreibung: Liefert den Streak-Bonus auf Basis der absoluten Streaklänge aus einer gestaffelten Konfigurationstabelle. +input: aktuelle Streaklänge `streak` und die Konfiguration `cfg` +output: höchster anwendbarer Streak-Bonuswert (oder 0) +entscheidungen: +- Es wird der größte passende Schwellenwert verwendet, da spätere Iterationen frühere überschreiben. + beleg: "if abs(streak) >= min_streak:" +- Streaks werden symmetrisch auf ihre Länge betrachtet, sodass Sieg- und Niederlagenserien gleichbehandelt werden. + beleg: "if abs(streak) >= min_streak:" +kanten: +- wird-genutzt-von: backend/tft/sim/economy.py::round_income + +## backend/tft/sim/economy.py::round_income +beschreibung: Aggregiert die gesamten Goldeinnahmen einer Runde aus Grundeinkommen, Zinsen, Streak-Bonus und Siegbonus. +input: Goldstand `gold`, Streak `streak`, Siegstatus `won`, Rundenlabel `round_label` und Konfiguration `cfg` +output: Gesamtes Goldeinkommen dieser Runde als Summe aller Komponenten +entscheidungen: +- Frühe Runden erhalten ein spezielles Grundeinkommen, andere fallen auf den Standardwert zurück. + beleg: "g[\"early_income\"].get(round_label, g[\"base_income\"])" +- Nur bei einem Rundensieg wird der Siegbonus addiert. + beleg: "if won:" +kanten: +- ruft-auf: backend/tft/sim/economy.py::interest +- ruft-auf: backend/tft/sim/economy.py::streak_gold + +## backend/tft/sim/economy.py::xp_to_next +beschreibung: Liefert die XP-Schwelle bis zum nächsten Level oder signalisiert das Erreichen des Maximallevels. +input: aktuelles Level `level` und die Konfiguration `cfg` +output: benötigte XP für das nächste Level, oder `None` am Maximallevel +entscheidungen: +- Am oder über dem Maximallevel wird `None` zurückgegeben, um einen weiteren Aufstieg zu verhindern. + beleg: "if level >= xp[\"max_level\"]:" +kanten: +- wird-genutzt-von: backend/tft/sim/economy.py::apply_xp + +## backend/tft/sim/economy.py::apply_xp +beschreibung: Wendet gewonnene XP auf einen Spieler an und levelt ihn automatisch so weit wie möglich auf. +input: aktuelles Level `level`, aktuelle XP `xp`, hinzugewonnene XP `gained` und Konfiguration `cfg` +output: aktualisiertes Level und verbleibende XP nach möglichen Levelaufstiegen +entscheidungen: +- Mehrere Levelaufstiege werden in einer Schleife konsumiert, solange XP reicht und das Maximallevel nicht erreicht ist. + beleg: "while (need := xp_to_next(level, cfg)) is not None and xp >= need:" +- Beim Erreichen des Maximallevels werden verbleibende XP verworfen, um Überlauf zu vermeiden. + beleg: "if level >= cfg[\"xp\"][\"max_level\"]:" +kanten: +- ruft-auf: backend/tft/sim/economy.py::xp_to_next diff --git a/.planer/einheiten/backend__tft__sim__game.py.md b/.planer/einheiten/backend__tft__sim__game.py.md new file mode 100644 index 0000000..81f22c3 --- /dev/null +++ b/.planer/einheiten/backend__tft__sim__game.py.md @@ -0,0 +1,71 @@ +# Einheiten: backend/tft/sim/game.py +stand: f41ac76b5789 + +## backend/tft/sim/game.py::pair_players +beschreibung: Ermittelt die PvP-Paarungen der lebenden Spieler und einen eventuellen Ghost-Gegner unter Vermeidung von Wiederholungsgegnern. +input: Liste lebender PlayerState und ein random.Random +output: Tupel aus Paaren, einem möglichen odd-Spieler und einem möglichen Ghost-Spieler +entscheidungen: +- Es werden bis zu 20 Shuffle-Versuche unternommen, um eine kollisionsfreie Paarung zu finden. + beleg: "for _ in range(20):" +- Wiederholungsgegner werden bei mehr als zwei Lebenden explizit ausgeschlossen. + beleg: "all(a.last_opponent != b.name for a, b in pairs)" +- Bei ungerader Spielerzahl wird ein Spieler als Ghost gegen einen zufälligen anderen gespiegelt. + beleg: "ghost_src = rng.choice([p for p in alive if p is not odd]) if odd else None" +- Die Paarungsinformation wird über last_opponent beidseitig festgehalten. + beleg: "a.last_opponent, b.last_opponent = b.name, a.name" +kanten: +- nutzt: backend/tft/sim/player.py::PlayerState + +## backend/tft/sim/game.py::Game +beschreibung: Orchestriert eine komplette TFT-Partie als endliche Zustandsmaschine über Runden, Spielerauswahl und Kampfauswertung. +input: artifact (dict), cfg (dict), optional seed (int) +output: ein lauffähiger Spielzustand mit Spieler, Bots, Rundenplan, Pool und Log, der per `step()` voranschreitet +entscheidungen: +- Der Mensch steuert nur `self.player`, Bots werden fest auf 7 Instanzen aus rotierenden Archetypen aufgeteilt. + beleg: "for i in range(7)" +- Carousels laufen automatisch für alle ab, ohne Einkommen oder XP zu gewähren. + beleg: "for p in self.players:" +- Bei Rundenstart werden leere Shop-Slots vorbereitet, da zu 1-2 noch niemand Gold hat. + beleg: "for p in self.players:" +- Streak-Gold und Siegbonus werden ausschließlich in PvP-Runden gewertet, in PvE nicht. + beleg: "if is_pvp:" +- Augment-Angebote werden deterministisch pro Runde aus einer gewichteten Stufe gezogen, nicht pro Spieler separat. + beleg: "self.rng.choices((1, 2, 3), weights=weights)[0]" +kanten: +- ruft-auf: backend/tft/sim/pool.py::Pool +- ruft-auf: backend/tft/sim/player.py::new_player +- ruft-auf: backend/tft/sim/player.py::score +- ruft-auf: backend/tft/sim/player.py::grant_loot +- ruft-auf: backend/tft/sim/player.py::grab_carousel_unit +- ruft-auf: backend/tft/sim/player.py::buy +- ruft-auf: backend/tft/sim/player.py::sell +- ruft-auf: backend/tft/sim/player.py::reroll +- ruft-auf: backend/tft/sim/player.py::buy_xp +- ruft-auf: backend/tft/sim/player.py::move +- ruft-auf: backend/tft/sim/player.py::equip +- ruft-auf: backend/tft/sim/player.py::pick_augment +- ruft-auf: backend/tft/sim/player.py::fill_board +- ruft-auf: backend/tft/sim/player.py::merge +- ruft-auf: backend/tft/sim/player.py::refresh_shop +- ruft-auf: backend/tft/sim/policy.py::act +- ruft-auf: backend/tft/sim/policy.py::ARCHETYPES +- ruft-auf: backend/tft/sim/economy.py::apply_xp +- ruft-auf: backend/tft/sim/economy.py::round_income +- ruft-auf: backend/tft/sim/combat.py::fight +- ruft-auf: backend/tft/sim/combat.py::player_damage +- ruft-auf: backend/tft/sim/combat.py::pair_players +- ruft-auf: backend/tft/sim/combat.py::board_profiles + +## backend/tft/sim/game.py::_delegate +beschreibung: Erzeugt eine Property, die Lese- und Schreibzugriff transparent an ein Attribut eines delegierten Objekts weiterreicht. +input: ein Attributname als String +output: ein `property`-Objekt mit Getter und Setter, die das gleichnamige Attribut auf `self.player` lesen bzw. schreiben +entscheidungen: +- Getter und Setter greifen beide über `self.player` auf das Zielattribut zu, sodass Aufrufer das Delegate-Objekt nicht vom echten Spieler unterscheiden müssen. + beleg: "lambda self: getattr(self.player, attr)" +- Schreibzugriff wird ebenfalls an `self.player` durchgereicht, wodurch das Delegate zweiseitig konsistent bleibt. + beleg: "lambda self, value: setattr(self.player, attr, value)" +- Die Attribute werden als ungeprüfte Strings übergeben, was eine sehr flexible, aber untypisierte Bindung an beliebige `player`-Attribute erlaubt. + beleg: "attr: str" +kanten: diff --git a/.planer/einheiten/backend__tft__sim__player.py.md b/.planer/einheiten/backend__tft__sim__player.py.md new file mode 100644 index 0000000..3953a73 --- /dev/null +++ b/.planer/einheiten/backend__tft__sim__player.py.md @@ -0,0 +1,228 @@ +# Einheiten: backend/tft/sim/player.py +stand: f41ac76b5789 + +## backend/tft/sim/player.py::InvalidAction +beschreibung: Markiert ungültige Spielaktionen als Ausnahme. +input: eine Fehlermeldung als Zeichenkette. +output: eine Exception, die von Aufrufern abgefangen wird. +entscheidungen: +- Wird als eigene Exception-Klasse statt generischer `Exception` definiert, damit Aufrufer gezielt reagieren können. + beleg: "class InvalidAction(Exception):" +- Enthält bewusst keine eigene Logik, sondern dient nur als Typ-Marker. + beleg: " pass" +kanten: +- wird-genutzt-von: backend/tft/sim/player.py::buy +- wird-genutzt-von: backend/tft/sim/player.py::sell +- wird-genutzt-von: backend/tft/sim/player.py::buy_xp +- wird-genutzt-von: backend/tft/sim/player.py::move +- wird-genutzt-von: backend/tft/sim/player.py::equip +- wird-genutzt-von: backend/tft/sim/player.py::pick_augment + +## backend/tft/sim/player.py::PlayerState +beschreibung: Hält den kompletten Zustand eines Spielers über alle Runden hinweg. +input: Initialwerte für Name, Archetyp, HP, Gold und Level. +output: ein Datensatz-Objekt mit Board, Bench, Items, Augments und Shop. +entscheidungen: +- Verwendet `field(default_factory=list)` für mutable Defaults, um die geteilte-Liste-Falle zu vermeiden. + beleg: " board: list = field(default_factory=list)" +- `alive` ist als abgeleitete Property modelliert, damit Konsistenz mit `hp` garantiert ist. + beleg: " return self.hp > 0" +- Optionale Felder wie `placement` und `last_opponent` werden explizit typisiert, damit Phasen ohne Gegner sauber abgebildet werden. + beleg: " placement: int | None = None" +kanten: +- nutzt: backend/tft/sim/player.py::score +- wird-genutzt-von: backend/tft/sim/player.py::new_player + +## backend/tft/sim/player.py::new_player +beschreibung: Erzeugt einen frischen `PlayerState` aus einer Konfiguration. +input: Name, Archetyp und ein Konfigurations-Dictionary. +output: ein neu initialisierter `PlayerState` mit Startwerten. +entscheidungen: +- Liest Startwerte aus `cfg`, um Regelvarianten ohne Code-Änderung zu erlauben. + beleg: " hp=cfg[\"damage\"][\"player_hp\"]," +- Fallback auf Level 1, falls `start_level` fehlt. + beleg: " level=cfg[\"xp\"].get(\"start_level\", 1)," +kanten: +- ruft-auf: backend/tft/sim/player.py::PlayerState +- nutzt: backend/tft/sim/player.py::PlayerState + +## backend/tft/sim/player.py::score +beschreibung: Berechnet die Spielstärke eines Spielers für die Schaden-Berechnung. +input: ein Spielerzustand und ein Artifact-Dictionary. +output: ein Float-Score des aktuellen Boards inklusive Augments. +entscheidungen: +- Delegiert die eigentliche Berechnung an `score_board`, um die Bewertungslogik zentral zu halten. + beleg: " return score_board(p.board, p.augments, artifact)" +- Bezieht Augments in den Score ein, damit gewählte Bonüsse kampfrelevant zählen. + beleg: " return score_board(p.board, p.augments, artifact)" +kanten: + +## backend/tft/sim/player.py::unit_worth +beschreibung: Liefert den ökonomischen Gegenwert einer Einheit für Aufstellungsentscheidungen. +input: Artifact-Dictionary und eine Einheit mit `api_name` und `stars`. +output: ein Float-Wert als Kombination aus Kosten und Sternen. +entscheidungen: +- Skaliert den Wert mit `3 ** (stars - 1)`, da Merge-Stufen das Vielfache an Einheiten ersetzen. + beleg: " return artifact[\"static\"][\"units\"][u[\"api_name\"]][\"cost\"] * 3 ** (u[\"stars\"] - 1)" +- Liest Einheitenkosten aus den statischen Artifact-Daten, damit Balance-Anpassungen ohne Code-Change greifen. + beleg: " return artifact[\"static\"][\"units\"][u[\"api_name\"]][\"cost\"] * 3 ** (u[\"stars\"] - 1)" +kanten: +- wird-genutzt-von: backend/tft/sim/player.py::fill_board + +## backend/tft/sim/player.py::refresh_shop +beschreibung: Erneuert die Shop-Anzeige eines Spielers mit neuen Roll-Ergebnissen. +input: Spielerzustand, Pool, Konfiguration und RNG. +output: keine Rückgabe; der Shop des Spielers wird ersetzt. +entscheidungen: +- Überschreibt `p.shop` vollständig statt zu mischen, um das TFT-typische „neuer Roll" zu modellieren. + beleg: " p.shop = roll(pool, p.level, cfg, rng)" +kanten: +- wird-genutzt-von: backend/tft/sim/player.py::reroll + +## backend/tft/sim/player.py::merge +beschreibung: Führt automatisch passende Einheiten gleicher Sternstufe zu höheren Sternen zusammen. +input: Spielerzustand, API-Name und aktuelle Sternstufe. +output: keine Rückgabe; Board und Bench werden konsolidiert. +entscheidungen: +- Iteriert mit `while True`, bis weniger als drei Kopien vorhanden sind. + beleg: " while True:" +- Übernimmt die Items der fusionierten Kopien auf die verbleibende Einheit. + beleg: " keep[\"items\"].extend(u[\"items\"])" +- Kürzt das Item-Inventar der behaltenen Einheit auf das maximale Limit. + beleg: " keep[\"items\"] = keep[\"items\"][:MAX_ITEMS_PER_UNIT]" +- Entfernt überschüssige Einheiten aus Board oder Bench je nach Herkunft. + beleg: " (p.board if u in p.board else p.bench).remove(u)" +kanten: +- wird-genutzt-von: backend/tft/sim/player.py::buy +- wird-genutzt-von: backend/tft/sim/player.py::grab_carousel_unit + +## backend/tft/sim/player.py::buy +beschreibung: Kauft eine Einheit aus dem Shop und legt sie auf die Bank. +input: Spielerzustand, Shop-Slot, Pool und Artifact-Dictionary. +output: keine Rückgabe; Bench und Pool werden verändert. +entscheidungen: +- Prüft Shop-Slot, Gold, Bank-Kapazität und Pool-Verfügbarkeit in dieser Reihenfolge, um Mehrfach-Meldungen pro Aktion zu vermeiden. + beleg: " if not (0 <= slot < len(p.shop)) or p.shop[slot] is None:" +- Leert den Shop-Slot nach erfolgreichem Kauf explizit. + beleg: " p.shop[slot] = None" +- Startet sofort einen Merge-Versuch auf Sternstufe 1, damit Käufe direkt stapeln. + beleg: " merge(p, api, 1)" +kanten: +- ruft-auf: backend/tft/sim/player.py::merge +- nutzt: backend/tft/sim/player.py::InvalidAction + +## backend/tft/sim/player.py::sell +beschreibung: Verkauft eine Einheit vom Board oder Bench und erstattet Gold und Items zurück. +input: Spielerzustand, Ort, Index, Pool und Artifact-Dictionary. +output: keine Rückgabe; Gold, Items und Pool werden aktualisiert. +entscheidungen: +- Berechnet die Anzahl der Pool-Copies exponentiell zur Sternstufe. + beleg: " copies = 3 ** (u[\"stars\"] - 1)" +- Zieht 1 Gold ab, sobald die Einheit nicht 1 kostet und mindestens 2 Sterne hat. + beleg: " if cost > 1 and u[\"stars\"] >= 2:" +- Gibt Items aus dem Inventar der Einheit in das Spieler-Inventar frei. + beleg: " p.items.extend(u[\"items\"])" +kanten: +- nutzt: backend/tft/sim/player.py::InvalidAction + +## backend/tft/sim/player.py::reroll +beschreibung: Würfelt den Shop gegen Gold neu aus. +input: Spielerzustand, Pool, Konfiguration und RNG. +output: keine Rückgabe; der Shop wird aktualisiert. +entscheidungen: +- Prüft Gold erst nach dem Lesen der Kosten, damit einheitliche Fehlermeldungen entstehen. + beleg: " if p.gold < cost:" +- Delegiert das eigentliche Neu-Würfeln an `refresh_shop`, um Logik-Duplikation zu vermeiden. + beleg: " refresh_shop(p, pool, cfg, rng)" +kanten: +- ruft-auf: backend/tft/sim/player.py::refresh_shop +- nutzt: backend/tft/sim/player.py::InvalidAction + +## backend/tft/sim/player.py::buy_xp +beschreibung: Kauft zusätzliche Erfahrungspunkte und aktualisiert das Level. +input: Spielerzustand und Konfiguration. +output: keine Rückgabe; Level und XP werden fortgeschrieben. +entscheidungen: +- Lehnt den Kauf ab, wenn das maximale Level bereits erreicht ist. + beleg: " if p.level >= xp[\"max_level\"]:" +- Übergibt die XP-Berechnung an `economy.apply_xp`, damit Level-Schwellen zentral gepflegt werden. + beleg: " p.level, p.xp = economy.apply_xp(p.level, p.xp, xp[\"buy_amount\"], cfg)" +kanten: +- nutzt: backend/tft/sim/player.py::InvalidAction + +## backend/tft/sim/player.py::move +beschreibung: Verschiebt eine Einheit zwischen Bank und Kampf-Board. +input: Spielerzustand, Richtung und Index. +output: keine Rückgabe; Board oder Bench werden mutiert. +entscheidungen: +- Wählt Quell- und Ziel-Liste abhängig von der Richtung in einer Zeile. + beleg: " src, dst = (p.bench, p.board) if where == \"bench\" else (p.board, p.bench)" +- Prüft die Board-Kapazität gegen das aktuelle Level des Spielers, nicht gegen einen festen Maximalwert. + beleg: " if dst is p.board and len(p.board) >= p.level:" +kanten: +- nutzt: backend/tft/sim/player.py::InvalidAction + +## backend/tft/sim/player.py::equip +beschreibung: Legt ein Item aus dem Inventar auf eine Board-Einheit und craftet bei passender Komponente. +input: Spielerzustand, Item-Index, Board-Index und optionales Artifact. +output: keine Rückgabe; Einheit und Inventar werden angepasst. +entscheidungen: +- Sortiert die beiden Komponenten vor dem Lookup, damit die Reihenfolge keine Rolle spielt. + beleg: " crafted = recipes.get(\"|\".join(sorted((held, new_item))))" +- Bricht die Funktion mit `return` ab, sobald ein Rezept erfolgreich gecraftet wurde. + beleg: " p.items.pop(item_idx)" + beleg: " return" +kanten: +- nutzt: backend/tft/sim/player.py::InvalidAction + +## backend/tft/sim/player.py::pick_augment +beschreibung: Wählt einen Augment aus dem aktuellen Angebot des Spielers. +input: Spielerzustand und Wahl-Index. +output: keine Rückgabe; Augments-Liste wird erweitert und Angebot geleert. +entscheidungen: +- Lehnt die Aktion ab, wenn aktuell kein Angebot vorliegt. + beleg: " if not p.augment_offer:" +- Leert das Angebot nach der Auswahl, damit kein zweiter Pick möglich ist. + beleg: " p.augment_offer = []" +kanten: +- nutzt: backend/tft/sim/player.py::InvalidAction + +## backend/tft/sim/player.py::grant_loot +beschreibung: Verteilt Loot-Belohnungen wie Komponenten und Gold an einen Spieler. +input: Spielerzustand, Anzahl Komponenten, Gold, Artifact und RNG. +output: keine Rückgabe; Inventar und Gold werden erhöht. +entscheidungen: +- Wählt die Item-Quelle in absteigender Priorität aus Komponenten- und Item-Pool. + beleg: " pool = (artifact.get(\"component_pool\") or artifact.get(\"item_pool\")" +- Erlaubt nur das Droppen von Komponenten, fertige Items entstehen ausschließlich durch Crafting. + beleg: " # Loot droppt Komponenten; fertige Items entstehen nur durch Craften." +kanten: +- nutzt: backend/tft/sim/player.py::InvalidAction + +## backend/tft/sim/player.py::grab_carousel_unit +beschreibung: Greift eine zufällige Karussell-Einheit und legt sie auf die Bank. +input: Spielerzustand, Pool und RNG. +output: keine Rückgabe; Bench und Pool werden verändert. +entscheidungen: +- Sammelt Kandidaten aus den Kostenstufen 1 bis 3, da Karussell-Einheiten günstig sind. + beleg: " candidates = [a for c in (1, 2, 3) for a in pool.units_of_cost(c)]" +- Überspringt den Greif, wenn die Bank voll ist, ohne Fehler zu werfen. + beleg: " if candidates and len(p.bench) < BENCH_SIZE:" +- Startet sofort einen Merge-Versuch, damit Drops direkt stapeln. + beleg: " merge(p, api, 1)" +kanten: +- ruft-auf: backend/tft/sim/player.py::merge + +## backend/tft/sim/player.py::fill_board +beschreibung: Füllt freie Board-Plätze und tauscht stärkere Bank-Einheiten ein. +input: Spielerzustand und Artifact-Dictionary. +output: keine Rückgabe; Board und Bench werden umsortiert. +entscheidungen: +- Sortiert die Bank absteigend nach `unit_worth`, damit starke Einheiten vorne stehen. + beleg: " p.bench.sort(key=lambda u: -unit_worth(artifact, u))" +- Tauscht Board-Einheiten nur aus, wenn der Bank-Kandidat strikt stärker ist. + beleg: " if unit_worth(artifact, best) > unit_worth(artifact, u):" +- Bricht die Swap-Schleife ab, sobald die Bank leer ist. + beleg: " if not p.bench:" +kanten: +- ruft-auf: backend/tft/sim/player.py::unit_worth diff --git a/.planer/einheiten/backend__tft__sim__pool.py.md b/.planer/einheiten/backend__tft__sim__pool.py.md new file mode 100644 index 0000000..f9d2b29 --- /dev/null +++ b/.planer/einheiten/backend__tft__sim__pool.py.md @@ -0,0 +1,19 @@ +# Einheiten: backend/tft/sim/pool.py +stand: f41ac76b5789 + +## backend/tft/sim/pool.py::Pool +beschreibung: Verwaltet einen endlichen Vorrat pro Champion-API-Einheit und regelt Entnahme sowie Rückgabe. +input: Konstruktor erhält ein Mapping von API-Namen zu Einheitendaten sowie eine Liste von Größen je Kostenstufe; Methoden nehmen API-Namen und Mengen entgegen. +output: Aktueller verfügbarer Bestand je Einheit, eine Liste lebender Einheiten eines bestimmten Kostens, sowie ein mutierter Vorrat nach Entnahme oder Rückgabe. +entscheidungen: +- Einheiten ohne gültige Kostenstufe (1 bis len(sizes)) werden aus dem Vorrat ausgeschlossen. + beleg: "if 1 <= u[\"cost\"] <= len(sizes)" +- Die Kosten jedes API-Namens werden in einem separaten Mapping gehalten, um günstig nach Kosten zu filtern. + beleg: "self.cost_of = {api: u[\"cost\"] for api, u in static_units.items()}" +- Eine Einheit gilt nur dann als verfügbar, wenn sie noch nicht aufgebraucht ist. + beleg: "self.copies[a] > 0" +- Eine Entnahme über den aktuellen Bestand hinaus wird als Fehler signalisiert. + beleg: "raise ValueError(f\"pool exhausted for {api_name}\")" +- Nicht im Vorrat enthaltene API-Namen werden bei Anfragen als null verfügbar behandelt. + beleg: "return self.copies.get(api_name, 0)" +kanten: diff --git a/.planer/einheiten/backend__tft__sim__rounds.py.md b/.planer/einheiten/backend__tft__sim__rounds.py.md new file mode 100644 index 0000000..5fbd2f7 --- /dev/null +++ b/.planer/einheiten/backend__tft__sim__rounds.py.md @@ -0,0 +1,17 @@ +# Einheiten: backend/tft/sim/rounds.py +stand: f41ac76b5789 + +## backend/tft/sim/rounds.py::schedule +beschreibung: Erzeugt die vollständige Stage-/Round-Folge eines Spiels inklusive Augment-Markierung. +input: ein Konfigurations-Dikt mit den Schlüsseln `rounds.stage1`, `rounds.stage_n` und `rounds.augment_rounds`. +output: eine Liste von Round-Dicts mit `label`, `stage`, `kind` und `augment`. +entscheidungen: +- Stage 1 wird separat aus `stage1` gelesen, alle weiteren Stages teilen sich `stage_n`. + beleg: "for i, kind in enumerate(r[\"stage1\"], start=1):" +- Stages werden ab 2 bis inklusive `MAX_STAGE` durchlaufen. + beleg: "for stage in range(2, MAX_STAGE + 1):" +- Die Augment-Markierung erfolgt nachträglich per Membership-Test gegen `augment_rounds`. + beleg: "rnd[\"augment\"] = rnd[\"label\"] in r[\"augment_rounds\"]" +- Labels folgen dem Schema `{stage}-{i}` mit 1-basierter Nummerierung je Stage. + beleg: "f\"{stage}-{i}\"" +kanten: diff --git a/.planer/einheiten/backend__tft__sim__shop.py.md b/.planer/einheiten/backend__tft__sim__shop.py.md new file mode 100644 index 0000000..6516bce --- /dev/null +++ b/.planer/einheiten/backend__tft__sim__shop.py.md @@ -0,0 +1,17 @@ +# Einheiten: backend/tft/sim/shop.py +stand: f41ac76b5789 + +## backend/tft/sim/shop.py::roll +beschreibung: Führt einen Shop-Roll aus und liefert eine Anzahl zufälliger Units gemäß Level-Odds und verbleibenden Pool-Kopien. +input: Pool-Instanz, Spieler-Level, Konfigurations-Dictionary mit Shop-Odds und Slot-Anzahl sowie ein random.Random-RNG. +output: Liste von Unit-Champion-IDs (oder None für leere Slots) in Länge der konfigurierten Slot-Anzahl. +entscheidungen: +- Die Tier-Odds werden level-indiziert aus der Konfiguration gelesen, sodass die Wahrscheinlichkeiten pro Level konfigurierbar bleiben. + beleg: "odds = cfg[\"shop\"][\"odds\"][level - 1]" +- Für jeden Slot wird zuerst per gewichtetem Zufall ein Tier gezogen, bevor unter den Units dieses Tiers ausgewählt wird. + beleg: "tier = rng.choices(range(1, 6), weights=odds)[0]" +- Steht kein Kandidat eines gezogenen Tiers im Pool zur Verfügung, wird der Slot als None belegt statt zu scheitern. + beleg: "if not candidates:" +- Die Auswahl unter Kandidaten erfolgt gewichtet nach verfügbaren Pool-Kopien jeder Unit. + beleg: "weights = [pool.available(a) for a in candidates]" +kanten: diff --git a/.planer/einheiten/backend__tft__staticdata__fetch.py.md b/.planer/einheiten/backend__tft__staticdata__fetch.py.md new file mode 100644 index 0000000..0cdef65 --- /dev/null +++ b/.planer/einheiten/backend__tft__staticdata__fetch.py.md @@ -0,0 +1,31 @@ +# Einheiten: backend/tft/staticdata/fetch.py +stand: f41ac76b5789 + +## backend/tft/staticdata/fetch.py::fetch_static +beschreibung: Lädt TFT-Rohdaten von Community Dragon, parst sie und schreibt sie zusammen mit Metadaten versioniert auf die Festplatte. +input: optionale `set_override`-ID, sonst aktuelle Set-Auswahl der Quelldaten +output: das geparste TFT-Statikdaten-Dictionary (zugleich als versionierte JSON-Dateien inklusive Pointer-Datei persistiert) +entscheidungen: +- Patch wird aus dem Content-Metadata-Endpoint auf die ersten zwei Versionssegmente reduziert. + beleg: "patch = \".\".join(version.split(\".\")[:2])" +- Rohdaten werden unverändert zusätzlich als `raw.json` archiviert. + beleg: "(out_dir / \"raw.json\").write_text(json.dumps(raw))" +- Geschriebene Domänen-Dateien werden mit eingerücktem, nicht-ASCII-escapiertem JSON serialisiert. + beleg: "json.dumps(parsed[name], indent=1, ensure_ascii=False)" +- Nach erfolgreichem Schreiben wird ein `latest_static_pointer` aktualisiert, damit Aufrufer das aktuelle Verzeichnis finden. + beleg: "paths.latest_static_pointer().write_text(json.dumps(pointer, indent=1))" +kanten: + +## backend/tft/staticdata/fetch.py::load_static +beschreibung: Lädt den zuletzt von `fetch_static` geschriebenen Satz normalisierter TFT-Statikdaten über den Pointer wieder ein. +input: nichts; liest selbstständig den Pointer-Pfad aus `tft.paths` +output: ein Dictionary mit den Schlüsseln `units`, `traits`, `items`, `augments` und `meta` aus dem zuletzt gespeicherten Statikdaten-Verzeichnis +entscheidungen: +- Das Zielverzeichnis wird über `paths.latest_static_pointer()` und nicht über einen Aufrufparameter bestimmt. + beleg: "pointer = json.loads(paths.latest_static_pointer().read_text())" +- Der `pathlib`-Import erfolgt erst innerhalb der Funktion, um den Modulimport schlank zu halten. + beleg: "from pathlib import Path" +- Beim Laden werden ausschließlich die fünf bekannten Domänen-Schlüssel zusammengeführt, unbekannte Einträge ignoriert. + beleg: "for name in (\"units\", \"traits\", \"items\", \"augments\", \"meta\")" +kanten: +- wird-genutzt-von: backend/tft/staticdata/fetch.py::fetch_static diff --git a/.planer/einheiten/backend__tft__staticdata__parse.py.md b/.planer/einheiten/backend__tft__staticdata__parse.py.md new file mode 100644 index 0000000..016f57d --- /dev/null +++ b/.planer/einheiten/backend__tft__staticdata__parse.py.md @@ -0,0 +1,86 @@ +# Einheiten: backend/tft/staticdata/parse.py +stand: f41ac76b5789 + +## backend/tft/staticdata/parse.py::augment_tier +beschreibung: Leitet die Augment-Stufe (I/II/III) aus dem Suffix des Icon-Dateinamens ab. +input: icon_path — optionaler Pfad zum Augment-Icon. +output: Ganzzahl 1–3 für die erkannte Stufe oder None bei fehlendem Pfad. +entscheidungen: +- Tier wird aus einem Römisch-Zahl-Suffix vor dem Punkt ermittelt, unabhängig von Groß-/Kleinschreibung. + beleg: "AUGMENT_TIER_RE = re.compile(r\"[-_](I{1,3})\.\", re.I)" +- Rückgabe ist die Länge der gematchten Römisch-Zahl, nicht ihr numerischer Wert. + beleg: "return len(m.group(1)) if m else None" +kanten: +- wird-genutzt-von: backend/tft/staticdata/parse.py::parse + +## backend/tft/staticdata/parse.py::render_desc +beschreibung: Füllt Platzhalter in einem Tooltip-Text mit Effekt-Werten und entfernt übriges Markup. +input: desc — Tooltip-Text mit @…@-Platzhaltern; effects — Dict mit numerischen Effekt-Werten. +output: Bereinigter, einsatzfertiger Beschreibungstext. +entscheidungen: +- Unbekannte Platzhalter werden als "?" dargestellt, statt den Text zu verwerfen. + beleg: "return \"?\"" +- HTML-Tags und %i:…%-Blöcke werden per Regex entfernt, bevor Mehrfach-Whitespace kollabiert wird. + beleg: "text = re.sub(r\"<[^>]+>\", \" \", text)" +- Ganze Zahlen werden ohne Dezimalstellen ausgegeben, andere auf eine Nachkommastelle gerundet. + beleg: "str(int(value)) if float(value).is_integer() else str(round(value, 1))" +kanten: +- wird-genutzt-von: backend/tft/staticdata/parse.py::parse + +## backend/tft/staticdata/parse.py::resolve_spell +beschreibung: Ermittelt Schadens-Array, Schadenstyp und Skalierung einer Champion-Fähigkeit aus dem Desc-Markup. +input: ability — Dict mit Desc-Text, Variablen und Schadens-Tags. +output: Dict mit spell_damage, spell_damage_type und spell_scaling. +entscheidungen: +- Erstes im Desc gefundenes Schadens-Tag gewinnt; zugehöriger Variablenname wird gegen "Modified"-Variante geprüft. + beleg: "array = get(name) or get(name.removeprefix(\"Modified\"))" +- Ohne expliziten Tag wird ADDamage+APDamage kombiniert, sonst greift die Fallback-Liste in festgelegter Reihenfolge. + beleg: "for name in DAMAGE_FALLBACKS:" +- Skalierung wird aus %i:scaleAP% / %i:scaleAD%-Markern im Desc abgeleitet, nicht aus Variablen. + beleg: "has_ap = \"%i:scaleAP%\" in desc" +kanten: +- wird-genutzt-von: backend/tft/staticdata/parse.py::parse + +## backend/tft/staticdata/parse.py::icon_url +beschreibung: Wandelt einen Community-Dragon-Asset-Pfad in eine URL zum PNG-Abbild um. +input: asset_path — interner Asset-Pfad mit Endung .tex oder .dds. +output: URL auf raw.communitydragon.org mit .png-Endung. +entscheidungen: +- Endungen werden case-insensitive ersetzt, sodass .TEX und .DDS ebenfalls zu .png werden. + beleg: "p = asset_path.lower()" +- Basis-URL wird als Modul-Konstante gehalten, nicht pro Aufruf neu gebaut. + beleg: "return f\"{CDRAGON_GAME}/{p}\"" +kanten: +- wird-genutzt-von: backend/tft/staticdata/parse.py::parse + +## backend/tft/staticdata/parse.py::detect_set +beschreibung: Wählt aus dem Rohtext die höchste Set-Nummer, die Champion-Daten enthält. +input: raw — vollständiges cdragon-JSON mit einem sets-Dict. +output: Integer der gewählten Set-Nummer. +entscheidungen: +- Nur Sets mit tatsächlichen Champions werden als Kandidaten zugelassen. + beleg: "if v.get(\"champions\")" +- Ohne Kandidat wird hart ein ValueError geworfen, statt zu raten. + beleg: "raise ValueError(\"no set with champions found in cdragon data\")" +kanten: +- wird-genutzt-von: backend/tft/staticdata/parse.py::parse + +## backend/tft/staticdata/parse.py::parse +beschreibung: Normalisiert das Rohtext-JSON in flache Tabellen für Units, Traits, Items und Augments. +input: raw — cdragon-Gesamtdaten; set_override — optionale Set-Nummer. +output: Dict mit meta, units, traits, items und augments. +entscheidungen: +- Minions und Beschwörungen werden über apiName bzw. leere Trait-Listen ausgefiltert, nicht über eine eigene Allowlist. + beleg: "if not c[\"traits\"] or \"Minion\" in c[\"apiName\"]:" +- Trait-Anzeigenamen werden nachträglich auf api-Namen gemappt, damit die Unit-Tabelle konsistente Identifier führt. + beleg: "u[\"traits\"] = [trait_name_to_api[name] for name in u[\"traits\"]]" +- Augments werden anhand von Präfix oder apiName-Inhalt gefiltert und um Shop-/Tooltip-Müll bereinigt. + beleg: "if \"MarketOffering\" in api or \"@\" in (i[\"name\"] or \"\")" +- Set-Nummer kann per Parameter überschrieben werden, sonst fällt parse auf detect_set zurück. + beleg: "set_number = set_override if set_override is not None else detect_set(raw)" +kanten: +- ruft-auf: backend/tft/staticdata/parse.py::detect_set +- ruft-auf: backend/tft/staticdata/parse.py::icon_url +- ruft-auf: backend/tft/staticdata/parse.py::resolve_spell +- ruft-auf: backend/tft/staticdata/parse.py::augment_tier +- ruft-auf: backend/tft/staticdata/parse.py::render_desc diff --git a/.planer/features/bedienung-und-schnittstellen.md b/.planer/features/bedienung-und-schnittstellen.md new file mode 100644 index 0000000..fb3555e --- /dev/null +++ b/.planer/features/bedienung-und-schnittstellen.md @@ -0,0 +1,20 @@ +# Bereich: Bedienung und Schnittstellen +beschreibung: Macht die Simulation und das Modell von außen ansprechbar — als Kommandozeilen-Werkzeug und als Dienst. + +## Feature: Partien über eine Schnittstelle anbieten [rand] +beschreibung: Erlaubt es, neue Partien zu starten, Aktionen auszuführen und Spielzustände abzurufen. + +### Nimmt Spielzüge entgegen und leitet sie an die Simulation weiter +einheiten: backend/tft/api/app.py::ActionRequest, backend/tft/api/app.py::action, backend/tft/api/app.py::_load_game_deps + +### Verwaltet laufende Partien und meldet deren Zustand +einheiten: backend/tft/api/app.py::new_game, backend/tft/api/app.py::state, backend/tft/api/app.py::serialize, backend/tft/api/app.py::unit_view, backend/tft/api/app.py::upgrade_hint + +### Meldet den Betriebszustand des Dienstes +einheiten: backend/tft/api/app.py::health + +## Feature: Werkzeuge über die Kommandozeile bedienen [rand] +beschreibung: Bündelt die wichtigsten Arbeitsschritte in einem Kommandozeilen-Werkzeug mit Unterbefehlen. + +### Verteilt eingegebene Befehle an die passenden Arbeitsschritte +einheiten: backend/tft/cli.py::main diff --git a/.planer/features/partie-simulation.md b/.planer/features/partie-simulation.md new file mode 100644 index 0000000..ef41a03 --- /dev/null +++ b/.planer/features/partie-simulation.md @@ -0,0 +1,32 @@ +# Bereich: Partie-Simulation +beschreibung: Spielt komplette TFT-Partien automatisch nach und liefert Platzierungen als Ergebnis. + +## Feature: Einzelne Aktionen eines Spielers ausführen [kern] +beschreibung: Bildet alle Spielzüge eines Spielers — Kauf, Verkauf, Aufstellen, Kombinieren, Würfeln — originalgetreu ab. + +### Verwaltet das Vermögen, das Leveln und den Einkauf von Einheiten +einheiten: backend/tft/sim/economy.py::apply_xp, backend/tft/sim/economy.py::xp_to_next, backend/tft/sim/economy.py::round_income, backend/tft/sim/economy.py::interest, backend/tft/sim/economy.py::streak_gold, backend/tft/sim/player.py::buy, backend/tft/sim/player.py::buy_xp, backend/tft/sim/player.py::sell + +### Bewegt Einheiten zwischen Bank und Brett und rüstet sie mit Items aus +einheiten: backend/tft/sim/player.py::move, backend/tft/sim/player.py::fill_board, backend/tft/sim/player.py::merge, backend/tft/sim/player.py::equip, backend/tft/sim/player.py::unit_worth, backend/tft/sim/player.py::score + +### Frischt den Shop auf, würfelt neu und vergibt Belohnungen +einheiten: backend/tft/sim/shop.py::roll, backend/tft/sim/player.py::refresh_shop, backend/tft/sim/player.py::reroll, backend/tft/sim/player.py::grant_loot, backend/tft/sim/player.py::grab_carousel_unit, backend/tft/sim/player.py::pick_augment, backend/tft/sim/player.py::InvalidAction + +### Hält den Gesamtzustand eines Spielers über die ganze Partie vor +einheiten: backend/tft/sim/player.py::PlayerState, backend/tft/sim/player.py::new_player, backend/tft/sim/pool.py::Pool + +## Feature: Komplette Partien durchspielen [kern] +beschreibung: Steuert eine ganze Partie über alle Runden, Kämpfe und Platzierungen hinweg. + +### Legt den Rundenplan fest und paart Gegner für den Kampf +einheiten: backend/tft/sim/rounds.py::schedule, backend/tft/sim/game.py::pair_players, backend/tft/sim/game.py::Game, backend/tft/sim/game.py::_delegate + +### Simuliert den Kampf zwischen zwei Teams +einheiten: backend/tft/sim/combat.py::FightResult, backend/tft/sim/combat.py::fight, backend/tft/sim/combat.py::_queue, backend/tft/sim/combat.py::_suffix_off, backend/tft/sim/combat.py::player_damage + +## Feature: Partien automatisch ausführen und auswerten [kern] +beschreibung: Lässt viele Partien automatisch durchlaufen und fasst ihre Platzierungen zusammen. + +### Spielt ganze Partien nach festen Spielstilen zu Ende +einheiten: backend/tft/sim/autoplay.py::play_afk, backend/tft/sim/autoplay.py::play_econ, backend/tft/sim/autoplay.py::run diff --git a/.planer/features/spieldaten-beschaffung.md b/.planer/features/spieldaten-beschaffung.md new file mode 100644 index 0000000..09abedb --- /dev/null +++ b/.planer/features/spieldaten-beschaffung.md @@ -0,0 +1,23 @@ +# Bereich: Spieldaten-Beschaffung +beschreibung: Stellt die statischen Spiel- und Live-Daten bereit, auf denen alles Weitere aufbaut. + +## Feature: Aktuelle Spieldaten automatisch laden [kern] +beschreibung: Holt die jeweils gültigen Konfigurations-, Einheiten-, Item- und Augment-Daten und hält sie versioniert vor. + +### Lädt den offiziellen Datenstand versioniert herunter und macht ihn abrufbar +einheiten: backend/tft/staticdata/fetch.py::fetch_static, backend/tft/staticdata/fetch.py::load_static, backend/tft/paths.py::latest_static_pointer, backend/tft/paths.py::static_dir + +### Bereitet Rohdaten in flache, einheitliche Tabellen auf +einheiten: backend/tft/staticdata/parse.py::parse, backend/tft/staticdata/parse.py::detect_set, backend/tft/staticdata/parse.py::augment_tier, backend/tft/staticdata/parse.py::icon_url, backend/tft/staticdata/parse.py::render_desc, backend/tft/staticdata/parse.py::resolve_spell + +### Liefert Set-spezifische Konfigurationswerte und Spielkonstanten +einheiten: backend/tft/constants/loader.py::load_constants, backend/tft/constants/loader.py::validate, backend/tft/cli.py::current_set + +## Feature: Profi-Matches für die Analyse sammeln [kern] +beschreibung: Bezieht gewertete Partien von Top-Spielern, prüft sie gegen die statischen Daten und speichert ihre End-Aufstellungen. + +### Ruft gewertete Partien ab und schreibt sie in eine lokale Datenbank +einheiten: backend/tft/matches/riot.py::RiotClient, backend/tft/matches/riot.py::api_key, backend/tft/matches/riot.py::_load_env, backend/tft/matches/crawl.py::crawl, backend/tft/db.py::connect + +### Überführt rohe Match-Daten in geprüfte Endaufstellungen +einheiten: backend/tft/matches/extract.py::extract_match, backend/tft/matches/extract.py::extract_all, backend/tft/matches/extract.py::validate_ids, backend/tft/matches/extract.py::patch_of diff --git a/.planer/features/stärkemodell-aus-daten-lernen.md b/.planer/features/stärkemodell-aus-daten-lernen.md new file mode 100644 index 0000000..129c3c8 --- /dev/null +++ b/.planer/features/stärkemodell-aus-daten-lernen.md @@ -0,0 +1,32 @@ +# Bereich: Stärkemodell aus Daten lernen +beschreibung: Leitet aus gesammelten Profi-Aufstellungen belastbare Stärke-Werte für jede Spielfigur ab. + +## Feature: Datenbasiertes Stärkemodell erzeugen [kern] +beschreibung: Lernt aus historischen Platzierungen, wie stark Einheiten, Eigenschaften, Items und Augments wirklich sind. + +### Aggregiert Endaufstellungen zu platzierungsabhängigen Stärkewerten +einheiten: backend/tft/model/learn.py::learn, backend/tft/model/learn.py::learn_from_db, backend/tft/model/learn.py::_multiplier + +### Kalibriert das Modell gegen reale Platzierungen und prüft seine Aussagekraft +einheiten: backend/tft/model/calibrate.py::calibrate, backend/tft/model/calibrate.py::_holdout_scores, backend/tft/model/calibrate.py::_ranks, backend/tft/model/calibrate.py::spearman, backend/tft/model/calibrate.py::board_from_row + +## Feature: Spielstärke einer Aufstellung messen [kern] +beschreibung: Bewertet eine konkrete Brett-Aufstellung mit einer reproduzierbaren Gesamtstärke. + +### Berechnet das Kampfprofil jeder Einheit auf dem Brett +einheiten: backend/tft/model/score.py::unit_profile, backend/tft/model/score.py::board_profiles, backend/tft/model/statsheet.py::unit_stats, backend/tft/model/statsheet.py::_apply_item_effects, backend/tft/model/statsheet.py::_empty_acc, backend/tft/model/statsheet.py::_fraction, backend/tft/model/statsheet.py::item_is_modeled, backend/tft/model/statsheet.py::trait_buffs + +### Ermittelt aktive Eigenschafts-Stufen und einen Gesamt-Score +einheiten: backend/tft/model/score.py::active_trait_tiers, backend/tft/model/score.py::score_board, backend/tft/model/baseline.py::classify_role, backend/tft/model/baseline.py::unit_value + +## Feature: Analyse-Bausteine versioniert bereitstellen [kern] +beschreibung: Bündelt das gelernte Modell mit allen Spiel-Bausteinen zu einem versionierten Paket für Simulation und Auswertung. + +### Stellt die kombinierbaren Items und ihre Komponenten-Pools bereit +einheiten: backend/tft/model/artifact.py::craftable_items, backend/tft/model/artifact.py::component_pool, backend/tft/model/artifact.py::recipes + +### Zerlegt Augments in maschinenlesbare Effekte +einheiten: backend/tft/model/augments.py::build_spec, backend/tft/model/augments.py::build_specs, backend/tft/model/augments.py::_num, backend/tft/model/artifact.py::_augment_specs + +### Setzt einen Deckelwert für Fähigkeits-Schaden und verwaltet Artefakt-Versionen +einheiten: backend/tft/model/artifact.py::spell_dps_cap, backend/tft/model/artifact.py::build, backend/tft/model/artifact.py::load, backend/tft/model/artifact.py::save, backend/tft/paths.py::artifact_dir, backend/tft/paths.py::latest_artifact_pointer diff --git a/.planer/flows.md b/.planer/flows.md new file mode 100644 index 0000000..e459add --- /dev/null +++ b/.planer/flows.md @@ -0,0 +1,51 @@ +# Flows: tft + +## Flow: Statische Daten laden und bereitstellen +1. Community-Dragon-Rohdaten werden heruntergeladen und versioniert auf die Festplatte geschrieben — backend/tft/staticdata/fetch.py::fetch_static +2. Beim Schreiben wird der zuletzt gespeicherte Statikdatensatz über den Pointer wieder eingelesen — backend/tft/staticdata/fetch.py::load_static +3. Das Rohtext-JSON wird in flache Tabellen für Units, Traits, Items und Augments normalisiert — backend/tft/staticdata/parse.py::parse +4. Parse wählt die höchste vorhandene Set-Nummer aus Champion-Daten, ergänzt Icon-URLs und leitet Augment-Stufen aus Suffixen ab — backend/tft/staticdata/parse.py::detect_set, backend/tft/staticdata/parse.py::icon_url, backend/tft/staticdata/parse.py::augment_tier +5. Schadens-Arrays, Schadenstyp und Skalierung einer Champion-Fähigkeit werden aus dem Desc-Markup ermittelt — backend/tft/staticdata/parse.py::resolve_spell +6. Platzhalter in Tooltip-Texten werden mit Effekt-Werten gefüllt und übriges Markup entfernt — backend/tft/staticdata/parse.py::render_desc + +## Flow: Analyse-Artefakt erzeugen +1. Die TOML-Konstantendatei für ein Set wird eingelesen — backend/tft/constants/loader.py::load_constants +2. Struktur und Plausibilität der Konstanten werden validiert — backend/tft/constants/loader.py::validate +3. Das versionierte Analyse-Artefakt wird als Vertrag zwischen Pipeline und Simulation gebaut — backend/tft/model/artifact.py::build +4. Aus den Unit-Stats wird das 90. Perzentil der Spell-DPS auf 1 Stern ohne Items als Deckelwert berechnet — backend/tft/model/artifact.py::spell_dps_cap, backend/tft/model/statsheet.py::unit_stats +5. Aus den statischen Items werden die standardmäßig kombinierbaren Items herausgefiltert und auf Komponenten-Schlüssel abgebildet — backend/tft/model/artifact.py::craftable_items, backend/tft/model/artifact.py::recipes +6. Der Pool an häufig genutzten Basis-Komponenten wird bestimmt — backend/tft/model/artifact.py::component_pool +7. Augment-Spezifikationen werden katalogweit erzeugt und nach API indiziert — backend/tft/model/artifact.py::_augment_specs, backend/tft/model/augments.py::build_specs +8. Das fertige Artefakt wird versionsspezifisch als JSON geschrieben und der Latest-Pointer aktualisiert — backend/tft/model/artifact.py::save, backend/tft/paths.py::artifact_dir, backend/tft/paths.py::latest_artifact_pointer + +## Flow: Matches crawlen und Endboards extrahieren +1. Der Riot-API-Key wird aus der Umgebung geladen, wobei `backend/.env` optional eingelesen wird — backend/tft/matches/riot.py::api_key, backend/tft/matches/riot.py::_load_env +2. Gewertete TFT-Matches von Challengern/GM-Spielern werden gecrawlt und gefiltert in der SQLite-DB persistiert — backend/tft/matches/crawl.py::crawl +3. Eine SQLite-Verbindung wird hergestellt und das Schema initialisiert — backend/tft/db.py::connect +4. Alle noch nicht verarbeiteten Roh-Matches werden gelesen, ihre Endboard-Zeilen extrahiert und persistiert — backend/tft/matches/extract.py::extract_all +5. Pro Teilnehmer werden die relevanten Felder in Endboard-Tupel überführt — backend/tft/matches/extract.py::extract_match +6. Der zweistellige Patch wird aus dem `game_version`-String abgeleitet — backend/tft/matches/extract.py::patch_of +7. IDs in Endboard-Zeilen, die nicht in den statischen Daten vorkommen, werden als Patch-Drift gezählt — backend/tft/matches/extract.py::validate_ids + +## Flow: Multiplikatoren aus Endboards lernen +1. Endboard-Zeilen eines Sets werden aus einer SQL-Verbindung gelesen — backend/tft/model/learn.py::learn_from_db +2. Endboard-Zeilen werden zu placementsbezogenen Multiplikatoren für Einheiten, Traits, Items, Augments und Paaren aggregiert — backend/tft/model/learn.py::learn +3. Pro Eintrag wird ein um Bayessche Schrumpfung zentrierter Stärke-Multiplikator aus den Platzierungen berechnet — backend/tft/model/learn.py::_multiplier + +## Flow: Headless-Simulation ausführen +1. Die vollständige Stage-/Round-Folge eines Spiels inklusive Augment-Markierung wird erzeugt — backend/tft/sim/rounds.py::schedule +2. n headless Spiele werden mit der gewählten Policy ausgeführt und Platzierungen aggregiert — backend/tft/sim/autoplay.py::run +3. Entweder wird ein Spiel ohne Eingriff zu Ende gespielt — backend/tft/sim/autoplay.py::play_afk +4. Oder ein Spiel wird mit der Econ-Policy zu Ende gespielt — backend/tft/sim/autoplay.py::play_econ +5. Eine vollständige Partie wird als endliche Zustandsmaschine über Runden und Kämpfe orchestriert — backend/tft/sim/game.py::Game +6. Die PvP-Paarungen der lebenden Spieler werden ermittelt — backend/tft/sim/game.py::pair_players, backend/tft/sim/game.py::pair_players +7. Pro Paarung wird ein deterministischer Kampf zweier Teams eventbasiert simuliert — backend/tft/sim/combat.py::fight, backend/tft/sim/combat.py::FightResult +8. Der Schaden, den ein Spieler nach der Runde erhält, wird abhängig von Stage und Überlebenden berechnet — backend/tft/sim/combat.py::player_damage + +## Flow: Spielzustand über die API beantworten +1. Eine neue TFT-Spiellauf-Instanz wird erstellt und im globalen Register registriert — backend/tft/api/app.py::new_game +2. Für eine Aktions-Anfrage werden benötigte statische Set-Daten und Konfigurationswerte geladen — backend/tft/api/app.py::_load_game_deps +3. Die Aktions-Anfrage wird interpretiert und an die passende Game-Methode weitergeleitet — backend/tft/api/app.py::action, backend/tft/api/app.py::ActionRequest +4. Der Spielzustand der Instanz wird als JSON-Bild inklusive Brett, Bank, Shop, Traits und Gegnern serialisiert — backend/tft/api/app.py::state, backend/tft/api/app.py::serialize +5. Interne Einheit-Darstellungen werden in die API-Antwortstruktur projiziert — backend/tft/api/app.py::unit_view +6. Als Außenwirkung wird zusätzlich der Aufstiegs-Hinweis in die nächste Sternstufe bei weiterem Kauf berechnet — backend/tft/api/app.py::upgrade_hint \ No newline at end of file diff --git a/.planer/kern.md b/.planer/kern.md new file mode 100644 index 0000000..35b1985 --- /dev/null +++ b/.planer/kern.md @@ -0,0 +1,4 @@ +# Kern: tft +Das Projekt `tft` bildet das komplette „Teamfight Tactics"-Spiel als Daten-, Lern- und Simulationssystem nach: Es bezieht offizielle Spielstände, zieht daraus Stärke-Werte für Einheiten, Gegenstände, Eigenschaften und Augments und kann damit komplette Partien automatisch durchspielen, bewerten und über eine Schnittstelle oder Kommandozeile bedienbar machen. + +tragende-einheiten: backend/tft/sim/game.py::Game, backend/tft/sim/player.py::PlayerState, backend/tft/staticdata/fetch.py::fetch_static, backend/tft/model/learn.py::learn, backend/tft/model/score.py::score_board, backend/tft/api/app.py::new_game diff --git a/.planer/pruefung.md b/.planer/pruefung.md new file mode 100644 index 0000000..a91ceb8 --- /dev/null +++ b/.planer/pruefung.md @@ -0,0 +1,6 @@ +# Prüfung: tft +tests: make test +lint: (keins vorhanden) +start: make dev +hinweise: +- (vom Scan erzeugtes Gerüst — bitte prüfen und ergänzen) diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py index e2c3788..9cde7ea 100644 --- a/backend/tests/test_game.py +++ b/backend/tests/test_game.py @@ -114,6 +114,18 @@ def _pool_total(game, api): return n +def test_step_keeps_player_board_setup(art, cfg): + """Die manuelle Aufstellung wird nie durch stärkere Bank-Units ersetzt.""" + game = Game(art, cfg, seed=8) + game.player.level = 2 + game.player.board = [{"api_name": "U1_0", "stars": 1, "items": []}, + {"api_name": "U1_1", "stars": 1, "items": []}] + game.player.bench = [{"api_name": "U5_0", "stars": 2, "items": []}] + before = [u["api_name"] for u in game.player.board] + game.step() + assert [u["api_name"] for u in game.player.board] == before + + def test_pool_conservation(art, cfg): for seed in (1, 2, 3): game = Game(art, cfg, seed=seed) diff --git a/backend/tests/test_parse.py b/backend/tests/test_parse.py index 6739090..c715742 100644 --- a/backend/tests/test_parse.py +++ b/backend/tests/test_parse.py @@ -60,6 +60,21 @@ def test_parse_resolves_spell_damage(): assert unit["spell_scaling"] == "ap" +def test_resolve_spell_on_attack_passive(): + diana_like = { + "desc": "Passive: Attacks deal " + "@ModifiedBonusDamageToAttacks@ (%i:scaleAP%) " + "bonus magic damage.", + "variables": [{"name": "BonusDamageToAttacks", "value": [0, 52, 78, 135, 230]}], + } + r = resolve_spell(diana_like) + assert r["spell_on_attack"] is True + assert r["spell_damage"] == [0, 52, 78, 135, 230] + + cast = parse(RAW, set_override=17)["units"]["TFT17_Mage"] + assert cast["spell_on_attack"] is False + + def test_resolve_spell_adaptive_and_fallback(): adaptive = { "desc": "Deal @TotalDamage@ damage. %i:scaleAP% %i:scaleAD%", diff --git a/backend/tests/test_statsheet.py b/backend/tests/test_statsheet.py index ad4f483..b785c38 100644 --- a/backend/tests/test_statsheet.py +++ b/backend/tests/test_statsheet.py @@ -45,6 +45,17 @@ def test_items_change_profile(): assert not statsheet.item_is_modeled(ITEMS["unmodeled"]) +def test_on_attack_spell_scales_with_attack_speed(): + # Pro-Attacke-Passive: DPS = Schaden × AS, unabhängig vom Mana-Zyklus. + spell = [0, 50, 75, 110, 0, 0, 0] + u = unit(spell=spell) + u["spell_on_attack"] = True + on_attack = statsheet.unit_stats(u, 1, [], ITEMS, None, None) + assert on_attack["spell_dps"] == pytest.approx(50 * 0.7) + cast = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None) + assert on_attack["spell_dps"] > cast["spell_dps"] + + def test_frontline_casts_more(): spell = [0, 300, 450, 700, 0, 0, 0] tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline") diff --git a/backend/tft/model/statsheet.py b/backend/tft/model/statsheet.py index 7fe13b5..427fdff 100644 --- a/backend/tft/model/statsheet.py +++ b/backend/tft/model/statsheet.py @@ -161,12 +161,16 @@ def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict, dmg *= 1 + acc["ap_flat"] / 100 if scaling in ("ad", "both"): dmg *= 1 + acc["ad_pct"] - frontline = FRONTLINE_MANA_PER_SEC if role == "frontline" else 0 - cast_rate = min( - (as_eff * MANA_PER_ATTACK + frontline + acc["mana_regen"]) / mana_gap, - CAST_RATE_CAP, - ) - spell_dps = dmg * cast_rate + if unit.get("spell_on_attack"): + # Passive: Bonus-Schaden pro Auto-Attacke, kein Mana-Zyklus. + spell_dps = dmg * as_eff + else: + frontline = FRONTLINE_MANA_PER_SEC if role == "frontline" else 0 + cast_rate = min( + (as_eff * MANA_PER_ATTACK + frontline + acc["mana_regen"]) / mana_gap, + CAST_RATE_CAP, + ) + spell_dps = dmg * cast_rate # Ausreißer-Guard: Spell-Rohwerte sind zwischen Champions nicht # vergleichbar (per-Hit vs. total). Relativer Deckel: max. 3× eigene # Auto-DPS; das Populations-Perzentil dient als Floor für AD-lose Caster. diff --git a/backend/tft/sim/autoplay.py b/backend/tft/sim/autoplay.py index acf2c6c..c8f3bf2 100644 --- a/backend/tft/sim/autoplay.py +++ b/backend/tft/sim/autoplay.py @@ -1,6 +1,6 @@ """Scripted policies playing full games headless — sanity check for the sim.""" -from tft.sim import policy +from tft.sim import player, policy from tft.sim.game import Game @@ -15,6 +15,8 @@ def play_econ(game: Game) -> int: while not game.over: policy.act(game.player, game.pool, game.artifact, game.cfg, game.rng, game.round, params) + # Skript-Spieler stellt wie ein Bot auf (step tauscht für Menschen nicht). + player.fill_board(game.player, game.artifact) game.step() return game.placement diff --git a/backend/tft/sim/game.py b/backend/tft/sim/game.py index dbc66b2..4035d0c 100644 --- a/backend/tft/sim/game.py +++ b/backend/tft/sim/game.py @@ -54,6 +54,7 @@ class Game: for p in self.players: p.shop = [None] * cfg["shop"]["slots"] self._maybe_offer_augment() + self._bots_act() # ---- helpers ---- @@ -81,6 +82,15 @@ class Game: if p is self.player: self.log.append(f"{self.round['label']}: Carousel") + def _bots_act(self) -> None: + """Bots ziehen beim Rundenstart — ihre Boards und Scores stehen damit + für die ganze Planungsphase fest und kämpfen unverändert.""" + for b in self.bots: + if b.alive: + policy.act(b, self.pool, self.artifact, self.cfg, self.rng, + self.round, policy.ARCHETYPES[b.archetype]) + player.fill_board(b, self.artifact) + def _maybe_offer_augment(self) -> None: if not self.round.get("augment"): return @@ -187,12 +197,10 @@ class Game: self.pick_augment(self.rng.randrange(len(self.player.augment_offer))) rnd = self.round - for b in self.bots: - if b.alive: - policy.act(b, self.pool, self.artifact, self.cfg, self.rng, rnd, - policy.ARCHETYPES[b.archetype]) - for p in self._alive(): - player.fill_board(p, self.artifact) + # Bots haben schon beim Rundenstart gezogen (_bots_act). + # swap=False: die manuelle Aufstellung des Spielers bleibt unangetastet. + if self.player.alive: + player.fill_board(self.player, self.artifact, swap=False) results = self._resolve(rnd) self._check_eliminations() @@ -308,6 +316,7 @@ class Game: continue player.refresh_shop(p, self.pool, self.cfg, self.rng) self._maybe_offer_augment() + self._bots_act() def _finish_by_hp(self) -> None: standings = sorted(self._alive(), key=lambda p: -p.hp) diff --git a/backend/tft/sim/player.py b/backend/tft/sim/player.py index 294ff99..ca12174 100644 --- a/backend/tft/sim/player.py +++ b/backend/tft/sim/player.py @@ -194,11 +194,15 @@ def grab_carousel_unit(p: PlayerState, pool: Pool, rng: random.Random) -> None: merge(p, api, 1) -def fill_board(p: PlayerState, artifact: dict) -> None: - """Freie Board-Plätze auffüllen und stärkere Bank-Units einwechseln.""" +def fill_board(p: PlayerState, artifact: dict, swap: bool = True) -> None: + """Freie Board-Plätze auffüllen; mit swap auch stärkere Bank-Units einwechseln. + + swap=False für den Menschen: seine Aufstellung wird nie überstimmt.""" p.bench.sort(key=lambda u: -unit_worth(artifact, u)) while len(p.board) < p.level and p.bench: p.board.append(p.bench.pop(0)) + if not swap: + return for i, u in enumerate(p.board): if not p.bench: break diff --git a/backend/tft/staticdata/parse.py b/backend/tft/staticdata/parse.py index 75d8bca..e55b166 100644 --- a/backend/tft/staticdata/parse.py +++ b/backend/tft/staticdata/parse.py @@ -18,6 +18,10 @@ DAMAGE_FALLBACKS = ( ) TAG_TO_TYPE = {"magicDamage": "magic", "physicalDamage": "physical", "trueDamage": "true"} +# "Attacks deal @X@" (Markup dazwischen erlaubt) — Schaden pro Auto-Attacke. +ATTACK_CTX_RE = re.compile(r"attacks deal\s*(?:<[^>]+>\s*)*$", re.I) +ATTACK_VAR_RE = re.compile(r"OnAttack|ToAttack|PerAttack") + # Augment-Stufe steckt im Icon-Dateinamen: _I / _II / _III. AUGMENT_TIER_RE = re.compile(r"[-_](I{1,3})\.", re.I) PLACEHOLDER_RE = re.compile(r"@([A-Za-z0-9_]+)(?:\*([\d.]+))?@") @@ -62,12 +66,18 @@ def resolve_spell(ability: dict) -> dict: dmg_type = None array = None + on_attack = False for m in DAMAGE_TAG_RE.finditer(desc): for vm in VAR_RE.finditer(m.group(2)): name = vm.group(1) array = get(name) or get(name.removeprefix("Modified")) if array: dmg_type = TAG_TO_TYPE[m.group(1)] + # Schaden pro Auto-Attacke statt pro Cast (z.B. Diana, Teemo): + # erkennbar am Variablennamen oder an "Attacks deal" vorm Tag. + context = desc[max(0, m.start() - 60):m.start()] + on_attack = bool(ATTACK_VAR_RE.search(name) + or ATTACK_CTX_RE.search(context)) break if array: break @@ -80,6 +90,7 @@ def resolve_spell(ability: dict) -> dict: for name in DAMAGE_FALLBACKS: array = get(name) if array: + on_attack = bool(ATTACK_VAR_RE.search(name)) break has_ap = "%i:scaleAP%" in desc @@ -90,6 +101,7 @@ def resolve_spell(ability: dict) -> dict: "spell_damage": array, "spell_damage_type": dmg_type or ("magic" if array else None), "spell_scaling": scaling, + "spell_on_attack": on_attack, }