Compare commits
10 Commits
c450841822
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
848c8ea1aa | ||
|
|
f41ac76b57 | ||
|
|
f7e569e519 | ||
|
|
28fb667005 | ||
|
|
afe2205cf7 | ||
|
|
feb59d8442 | ||
|
|
54a3e7f9be | ||
|
|
12603267bc | ||
|
|
c82c9b0b01 | ||
|
|
f53f1e5c14 |
44
.planer/architektur.md
Normal file
44
.planer/architektur.md
Normal file
@@ -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
|
||||||
107
.planer/einheiten/backend__tft__api__app.py.md
Normal file
107
.planer/einheiten/backend__tft__api__app.py.md
Normal file
@@ -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:
|
||||||
34
.planer/einheiten/backend__tft__cli.py.md
Normal file
34
.planer/einheiten/backend__tft__cli.py.md
Normal file
@@ -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
|
||||||
34
.planer/einheiten/backend__tft__constants__loader.py.md
Normal file
34
.planer/einheiten/backend__tft__constants__loader.py.md
Normal file
@@ -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
|
||||||
17
.planer/einheiten/backend__tft__db.py.md
Normal file
17
.planer/einheiten/backend__tft__db.py.md
Normal file
@@ -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:
|
||||||
19
.planer/einheiten/backend__tft__matches__crawl.py.md
Normal file
19
.planer/einheiten/backend__tft__matches__crawl.py.md
Normal file
@@ -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:
|
||||||
61
.planer/einheiten/backend__tft__matches__extract.py.md
Normal file
61
.planer/einheiten/backend__tft__matches__extract.py.md
Normal file
@@ -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
|
||||||
48
.planer/einheiten/backend__tft__matches__riot.py.md
Normal file
48
.planer/einheiten/backend__tft__matches__riot.py.md
Normal file
@@ -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
|
||||||
116
.planer/einheiten/backend__tft__model__artifact.py.md
Normal file
116
.planer/einheiten/backend__tft__model__artifact.py.md
Normal file
@@ -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
|
||||||
46
.planer/einheiten/backend__tft__model__augments.py.md
Normal file
46
.planer/einheiten/backend__tft__model__augments.py.md
Normal file
@@ -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
|
||||||
32
.planer/einheiten/backend__tft__model__baseline.py.md
Normal file
32
.planer/einheiten/backend__tft__model__baseline.py.md
Normal file
@@ -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
|
||||||
69
.planer/einheiten/backend__tft__model__calibrate.py.md
Normal file
69
.planer/einheiten/backend__tft__model__calibrate.py.md
Normal file
@@ -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
|
||||||
46
.planer/einheiten/backend__tft__model__learn.py.md
Normal file
46
.planer/einheiten/backend__tft__model__learn.py.md
Normal file
@@ -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
|
||||||
57
.planer/einheiten/backend__tft__model__score.py.md
Normal file
57
.planer/einheiten/backend__tft__model__score.py.md
Normal file
@@ -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
|
||||||
95
.planer/einheiten/backend__tft__model__statsheet.py.md
Normal file
95
.planer/einheiten/backend__tft__model__statsheet.py.md
Normal file
@@ -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
|
||||||
50
.planer/einheiten/backend__tft__paths.py.md
Normal file
50
.planer/einheiten/backend__tft__paths.py.md
Normal file
@@ -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
|
||||||
45
.planer/einheiten/backend__tft__sim__autoplay.py.md
Normal file
45
.planer/einheiten/backend__tft__sim__autoplay.py.md
Normal file
@@ -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
|
||||||
76
.planer/einheiten/backend__tft__sim__combat.py.md
Normal file
76
.planer/einheiten/backend__tft__sim__combat.py.md
Normal file
@@ -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
|
||||||
59
.planer/einheiten/backend__tft__sim__economy.py.md
Normal file
59
.planer/einheiten/backend__tft__sim__economy.py.md
Normal file
@@ -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
|
||||||
71
.planer/einheiten/backend__tft__sim__game.py.md
Normal file
71
.planer/einheiten/backend__tft__sim__game.py.md
Normal file
@@ -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:
|
||||||
228
.planer/einheiten/backend__tft__sim__player.py.md
Normal file
228
.planer/einheiten/backend__tft__sim__player.py.md
Normal file
@@ -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
|
||||||
19
.planer/einheiten/backend__tft__sim__pool.py.md
Normal file
19
.planer/einheiten/backend__tft__sim__pool.py.md
Normal file
@@ -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:
|
||||||
17
.planer/einheiten/backend__tft__sim__rounds.py.md
Normal file
17
.planer/einheiten/backend__tft__sim__rounds.py.md
Normal file
@@ -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:
|
||||||
17
.planer/einheiten/backend__tft__sim__shop.py.md
Normal file
17
.planer/einheiten/backend__tft__sim__shop.py.md
Normal file
@@ -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:
|
||||||
31
.planer/einheiten/backend__tft__staticdata__fetch.py.md
Normal file
31
.planer/einheiten/backend__tft__staticdata__fetch.py.md
Normal file
@@ -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
|
||||||
86
.planer/einheiten/backend__tft__staticdata__parse.py.md
Normal file
86
.planer/einheiten/backend__tft__staticdata__parse.py.md
Normal file
@@ -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
|
||||||
20
.planer/features/bedienung-und-schnittstellen.md
Normal file
20
.planer/features/bedienung-und-schnittstellen.md
Normal file
@@ -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
|
||||||
32
.planer/features/partie-simulation.md
Normal file
32
.planer/features/partie-simulation.md
Normal file
@@ -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
|
||||||
23
.planer/features/spieldaten-beschaffung.md
Normal file
23
.planer/features/spieldaten-beschaffung.md
Normal file
@@ -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
|
||||||
32
.planer/features/stärkemodell-aus-daten-lernen.md
Normal file
32
.planer/features/stärkemodell-aus-daten-lernen.md
Normal file
@@ -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
|
||||||
51
.planer/flows.md
Normal file
51
.planer/flows.md
Normal file
@@ -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
|
||||||
4
.planer/kern.md
Normal file
4
.planer/kern.md
Normal file
@@ -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
|
||||||
6
.planer/pruefung.md
Normal file
6
.planer/pruefung.md
Normal file
@@ -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)
|
||||||
92
backend/tests/test_combat.py
Normal file
92
backend/tests/test_combat.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from tft.sim import combat
|
||||||
|
|
||||||
|
|
||||||
|
def prof(off, dfn, defensive=True, name="u"):
|
||||||
|
return {"off": off, "def": dfn, "defensive": defensive,
|
||||||
|
"unit": {"api_name": name, "stars": 1, "items": []}}
|
||||||
|
|
||||||
|
|
||||||
|
CFG = {
|
||||||
|
"combat": {"max_frames": 1800},
|
||||||
|
"damage": {"stage_base": [0, 2, 5, 8, 10, 12, 17, 17],
|
||||||
|
"per_surviving_unit": 1, "player_hp": 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_kill_time_matches_dps_at_60fps():
|
||||||
|
# Def 10 gegen Gesamt-Off 5 -> 2 s = 120 Frames.
|
||||||
|
a = [prof(5, 1000)]
|
||||||
|
b = [prof(0, 10)]
|
||||||
|
res = combat.fight(a, b, random.Random(1), CFG)
|
||||||
|
assert res.winner == "a"
|
||||||
|
assert res.frames == 120
|
||||||
|
assert res.survivors_b == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_overflow_carries_to_next_unit():
|
||||||
|
# Zwei Units à 10 Def gegen Off 5 -> exakt 4 s, kein Schaden verpufft.
|
||||||
|
a = [prof(5, 1000)]
|
||||||
|
b = [prof(0, 10), prof(0, 10)]
|
||||||
|
res = combat.fight(a, b, random.Random(1), CFG)
|
||||||
|
assert res.winner == "a"
|
||||||
|
assert res.frames == 240
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_units_stop_contributing_offense():
|
||||||
|
# B killt As Front nach 2 s; danach kämpft A nur noch mit halber Offense.
|
||||||
|
a = [prof(6, 12, defensive=True), prof(6, 1000, defensive=False)]
|
||||||
|
b = [prof(6, 60)]
|
||||||
|
res = combat.fight(a, b, random.Random(1), CFG)
|
||||||
|
assert res.winner == "a"
|
||||||
|
assert res.frames == 480 # 2 s + 36/6 s statt 60/12 s bei konstanter Offense
|
||||||
|
assert len(res.survivors_a) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_targets_defensive_units_first():
|
||||||
|
team = [prof(1, 1, defensive=False, name="carry1"),
|
||||||
|
prof(1, 1, defensive=True, name="tank1"),
|
||||||
|
prof(1, 1, defensive=False, name="carry2"),
|
||||||
|
prof(1, 1, defensive=True, name="tank2")]
|
||||||
|
q = combat._queue(team, random.Random(5))
|
||||||
|
assert [p["defensive"] for p in q] == [True, True, False, False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_simultaneous_last_kill_is_draw():
|
||||||
|
a = [prof(5, 10)]
|
||||||
|
b = [prof(5, 10)]
|
||||||
|
res = combat.fight(a, b, random.Random(1), CFG)
|
||||||
|
assert res.winner is None
|
||||||
|
assert res.survivors_a == [] and res.survivors_b == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_boards():
|
||||||
|
res = combat.fight([], [], random.Random(1), CFG)
|
||||||
|
assert res.winner is None and res.frames == 0
|
||||||
|
res = combat.fight([prof(1, 1)], [], random.Random(1), CFG)
|
||||||
|
assert res.winner == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stall_hits_time_limit_more_defense_wins():
|
||||||
|
a = [prof(0, 100)]
|
||||||
|
b = [prof(0, 50)]
|
||||||
|
res = combat.fight(a, b, random.Random(1), CFG)
|
||||||
|
assert res.winner == "a"
|
||||||
|
assert res.frames == CFG["combat"]["max_frames"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_seed_same_result():
|
||||||
|
team_a = [prof(7, 300), prof(12, 150, defensive=False), prof(5, 400)]
|
||||||
|
team_b = [prof(9, 250), prof(10, 200, defensive=False)]
|
||||||
|
r1 = combat.fight(team_a, team_b, random.Random(42), CFG)
|
||||||
|
r2 = combat.fight(team_a, team_b, random.Random(42), CFG)
|
||||||
|
assert (r1.winner, r1.frames) == (r2.winner, r2.frames)
|
||||||
|
assert [p["api_name"] for p in r1.survivors_a] == \
|
||||||
|
[p["api_name"] for p in r2.survivors_a]
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_damage_counts_exact_survivors():
|
||||||
|
survivors = [{"api_name": f"u{i}"} for i in range(4)]
|
||||||
|
assert combat.player_damage(3, survivors, CFG) == 5 + 4
|
||||||
|
assert combat.player_damage(1, [], CFG) == 0
|
||||||
@@ -114,6 +114,18 @@ def _pool_total(game, api):
|
|||||||
return n
|
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):
|
def test_pool_conservation(art, cfg):
|
||||||
for seed in (1, 2, 3):
|
for seed in (1, 2, 3):
|
||||||
game = Game(art, cfg, seed=seed)
|
game = Game(art, cfg, seed=seed)
|
||||||
@@ -173,17 +185,24 @@ def test_pairing():
|
|||||||
|
|
||||||
def test_ghost_source_takes_no_damage(art, cfg):
|
def test_ghost_source_takes_no_damage(art, cfg):
|
||||||
game = Game(art, cfg, seed=3)
|
game = Game(art, cfg, seed=3)
|
||||||
game.bots[0].hp = 0
|
# Auf 3 Lebende reduzieren -> genau 1 Paar + 1 Ghost-Kampf.
|
||||||
game.bots[0].placement = 8
|
for b in game.bots[2:]:
|
||||||
game.bots[0].board, game.bots[0].bench = [], []
|
b.hp, b.placement, b.board, b.bench = 0, 8, [], []
|
||||||
# 7 Lebende -> jede PvP-Runde hat einen Ghost-Kampf; HP-Summe der
|
boards = [
|
||||||
# Ghost-Quelle darf nur durch ihren echten Kampf sinken (kein Doppelschaden).
|
(game.player, [{"api_name": "U5_0", "stars": 3, "items": []}]),
|
||||||
hp_before = {p.name: p.hp for p in game.players}
|
(game.bots[0], [{"api_name": "U3_0", "stars": 2, "items": []}]),
|
||||||
while game.round["kind"] != "pvp":
|
(game.bots[1], [{"api_name": "U1_0", "stars": 1, "items": []}]),
|
||||||
game.step()
|
]
|
||||||
|
for p, board in boards:
|
||||||
|
p.board, p.bench, p.gold = board, [], 0
|
||||||
|
game.idx = next(i for i, r in enumerate(game.rounds) if r["kind"] == "pvp")
|
||||||
|
hp_before = {p.name: p.hp for p in game._alive()}
|
||||||
game.step()
|
game.step()
|
||||||
drops = sum(1 for p in game.players if p.hp < hp_before[p.name])
|
# Nur Paar-Verlierer + ggf. Ghost-Verlierer nehmen Schaden; die
|
||||||
assert drops <= 4 # max. 3 Paar-Verlierer + 1 Ghost-Verlierer
|
# Ghost-Quelle darf durch ihren Klon-Kampf nicht getroffen werden.
|
||||||
|
drops = sum(1 for p in game.players if p.name in hp_before
|
||||||
|
and p.hp < hp_before[p.name])
|
||||||
|
assert drops <= 2
|
||||||
|
|
||||||
|
|
||||||
def test_pve_freezes_streak_and_pays_no_win_bonus(art, cfg):
|
def test_pve_freezes_streak_and_pays_no_win_bonus(art, cfg):
|
||||||
@@ -208,3 +227,74 @@ def test_natural_level_progression(art, cfg):
|
|||||||
assert checks["2-1"] == (3, 2)
|
assert checks["2-1"] == (3, 2)
|
||||||
assert checks["2-5"] == (4, 2)
|
assert checks["2-5"] == (4, 2)
|
||||||
assert checks["3-2"] == (5, 0)
|
assert checks["3-2"] == (5, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_augment_offer_single_tier(art, cfg):
|
||||||
|
game = Game(art, cfg, seed=9)
|
||||||
|
mixed = {f"AUG_T{t}_{i}": {"tier": t} for t in (1, 2, 3) for i in range(4)}
|
||||||
|
game.artifact = {**art, "static": {**art["static"], "augments": mixed},
|
||||||
|
"augment_pool": []}
|
||||||
|
game.idx = next(i for i, r in enumerate(game.rounds) if r["augment"])
|
||||||
|
game.player.augment_offer = []
|
||||||
|
game._maybe_offer_augment()
|
||||||
|
tiers = {mixed[a]["tier"] for a in game.player.augment_offer}
|
||||||
|
assert len(game.player.augment_offer) == 3
|
||||||
|
assert len(tiers) == 1 # alle Angebote aus derselben Stufe
|
||||||
|
|
||||||
|
|
||||||
|
def test_equip_crafts_completed_item(art, cfg):
|
||||||
|
game = Game(art, cfg, seed=1)
|
||||||
|
craft_art = {**art, "component_pool": ["C_A", "C_B"],
|
||||||
|
"recipes": {"C_A|C_B": "COMPLETED"}}
|
||||||
|
game.artifact = craft_art
|
||||||
|
game.board = [{"api_name": "U1_0", "stars": 1, "items": ["C_A"]}]
|
||||||
|
game.items = ["C_B", "C_A"]
|
||||||
|
game.equip(0, 0) # C_B auf Unit mit C_A -> COMPLETED
|
||||||
|
assert game.board[0]["items"] == ["COMPLETED"]
|
||||||
|
game.equip(0, 0) # C_A ohne Partner -> eigener Slot
|
||||||
|
assert game.board[0]["items"] == ["COMPLETED", "C_A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_loot_drops_components(art, cfg):
|
||||||
|
comp_art = {**art, "component_pool": ["C_A", "C_B"]}
|
||||||
|
game = Game(comp_art, cfg, seed=2)
|
||||||
|
assert all(i in ("C_A", "C_B") for i in game.items) # Carousel-Drop
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_leveling_follows_targets(art, cfg):
|
||||||
|
from tft.sim import policy as policy_mod
|
||||||
|
|
||||||
|
game = Game(art, cfg, seed=12)
|
||||||
|
levels_at = {}
|
||||||
|
while not game.over and game.round["label"] != "4-3":
|
||||||
|
label = game.round["label"]
|
||||||
|
if label in ("2-6", "3-3", "4-2"):
|
||||||
|
levels_at[label] = [b.level for b in game.bots if b.alive]
|
||||||
|
policy_mod.act(game.player, game.pool, game.artifact, game.cfg, game.rng,
|
||||||
|
game.round, policy_mod.ARCHETYPES["fast8"])
|
||||||
|
game.step()
|
||||||
|
assert all(lvl >= 5 for lvl in levels_at["2-6"]) # L5 ab 2-5
|
||||||
|
# L6 ab 3-1/3-2 — goldarme Bots dürfen 1-2 Runden nachhinken
|
||||||
|
assert sum(1 for lvl in levels_at["3-3"] if lvl >= 6) >= len(levels_at["3-3"]) - 2
|
||||||
|
assert any(lvl >= 7 for lvl in levels_at["4-2"]) # fast8/streaker auf 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_augment_effects_apply(art, cfg):
|
||||||
|
game = Game(art, cfg, seed=3)
|
||||||
|
game.artifact = {**art, "augment_specs": {
|
||||||
|
"AUG_GOLD": {"atoms": [{"kind": "gold_now", "amount": 7},
|
||||||
|
{"kind": "gold_per_stage", "amount": 7}], "offerable": True},
|
||||||
|
"AUG_UNIT": {"atoms": [{"kind": "unit_grant", "cost": 2, "count": 2}], "offerable": True},
|
||||||
|
}}
|
||||||
|
gold_before = game.player.gold
|
||||||
|
game.player.augment_offer = ["AUG_GOLD"]
|
||||||
|
game.pick_augment(0)
|
||||||
|
assert game.player.gold == gold_before + 7
|
||||||
|
assert game.player.gold_per_stage == 7
|
||||||
|
|
||||||
|
bench_before = len(game.player.bench)
|
||||||
|
pool_before = sum(game.pool.available(a) for a in game.pool.copies)
|
||||||
|
game.player.augment_offer = ["AUG_UNIT"]
|
||||||
|
game.pick_augment(0)
|
||||||
|
assert len(game.player.bench) == bench_before + 2
|
||||||
|
assert sum(game.pool.available(a) for a in game.pool.copies) == pool_before - 2
|
||||||
|
|||||||
@@ -62,3 +62,27 @@ def test_unit_multipliers_learned():
|
|||||||
learned = learn(rows)
|
learned = learn(rows)
|
||||||
assert learned["units"]["TFT17_Strong"] > 1.05
|
assert learned["units"]["TFT17_Strong"] > 1.05
|
||||||
assert learned["units"]["TFT17_Weak"] < 0.95
|
assert learned["units"]["TFT17_Weak"] < 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def test_augment_spec_builder():
|
||||||
|
from tft.model.augments import build_spec
|
||||||
|
|
||||||
|
units = {"TFT17_Briar": {"name": "Briar", "cost": 1}}
|
||||||
|
gold = build_spec({"desc": "Gain 6 gold now.", "effects": {"Gold": 6.0}}, units)
|
||||||
|
assert gold["atoms"] == [{"kind": "gold_now", "amount": 6}]
|
||||||
|
|
||||||
|
buff = build_spec({"desc": "Your team gains 35 Health and 10% Attack Speed.",
|
||||||
|
"effects": {"Health": 35.0, "AS": 0.10}}, units)
|
||||||
|
kinds = {(a["kind"], a.get("stat")) for a in buff["atoms"]}
|
||||||
|
assert ("team_buff", "hp_flat") in kinds
|
||||||
|
assert ("team_buff", "as_pct") in kinds
|
||||||
|
|
||||||
|
cond = build_spec({"desc": "Your team gains 10 Health for each item.",
|
||||||
|
"effects": {"Health": 10.0}}, units)
|
||||||
|
assert not any(a["kind"] == "team_buff" for a in cond["atoms"])
|
||||||
|
|
||||||
|
unit = build_spec({"desc": "Gain a Briar and 2 gold.", "effects": {"Gold": 2.0}}, units)
|
||||||
|
assert {"kind": "unit_grant", "units": ["TFT17_Briar"]} in unit["atoms"]
|
||||||
|
|
||||||
|
junk = build_spec({"desc": "", "effects": {}}, units)
|
||||||
|
assert junk["offerable"] is False
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ def test_parse_resolves_spell_damage():
|
|||||||
assert unit["spell_scaling"] == "ap"
|
assert unit["spell_scaling"] == "ap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_spell_on_attack_passive():
|
||||||
|
diana_like = {
|
||||||
|
"desc": "<spellPassive>Passive:</spellPassive> Attacks deal "
|
||||||
|
"<magicDamage>@ModifiedBonusDamageToAttacks@ (%i:scaleAP%)</magicDamage> "
|
||||||
|
"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():
|
def test_resolve_spell_adaptive_and_fallback():
|
||||||
adaptive = {
|
adaptive = {
|
||||||
"desc": "Deal <magicDamage>@TotalDamage@</magicDamage> damage. %i:scaleAP% %i:scaleAD%",
|
"desc": "Deal <magicDamage>@TotalDamage@</magicDamage> damage. %i:scaleAP% %i:scaleAD%",
|
||||||
@@ -75,3 +90,18 @@ def test_resolve_spell_adaptive_and_fallback():
|
|||||||
utility = {"desc": "Grant a shield.", "variables": [
|
utility = {"desc": "Grant a shield.", "variables": [
|
||||||
{"name": "Shield", "value": [0, 300, 400, 500, 0, 0, 0]}]}
|
{"name": "Shield", "value": [0, 300, 400, 500, 0, 0, 0]}]}
|
||||||
assert resolve_spell(utility)["spell_damage"] is None
|
assert resolve_spell(utility)["spell_damage"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_augment_tier_from_icon():
|
||||||
|
from tft.staticdata.parse import augment_tier
|
||||||
|
assert augment_tier("ASSETS/x/GodAugmentEkko_II.TFT_Set17.tex") == 2
|
||||||
|
assert augment_tier("ASSETS/x/LeonaHero_I.TFT_Set17.tex") == 1
|
||||||
|
assert augment_tier("ASSETS/x/Big_III.tex") == 3
|
||||||
|
assert augment_tier("ASSETS/x/NoSuffix.tex") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_desc():
|
||||||
|
from tft.staticdata.parse import render_desc
|
||||||
|
desc = "Gain <TFTBonus>@Gold@</TFTBonus> gold and @ADAP*100@% AD.<br>Rest."
|
||||||
|
assert render_desc(desc, {"Gold": 4, "ADAP": 0.15}) == "Gain 4 gold and 15% AD. Rest."
|
||||||
|
assert render_desc("@Unknown@ gold", {}) == "? gold"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import pytest
|
|||||||
|
|
||||||
from tft.model.baseline import classify_role, unit_value
|
from tft.model.baseline import classify_role, unit_value
|
||||||
from tft.model.calibrate import spearman
|
from tft.model.calibrate import spearman
|
||||||
from tft.model.score import active_trait_tiers, score_board
|
from tft.model.score import active_trait_tiers, board_profiles, score_board
|
||||||
|
|
||||||
STATIC = {
|
STATIC = {
|
||||||
"units": {
|
"units": {
|
||||||
@@ -28,8 +28,15 @@ STATIC = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ARTIFACT = {"meta": {"set": 17}, "static": {**STATIC, "items": {}, "augments": {}},
|
ARTIFACT = {
|
||||||
"roles": {}, "learned": {}}
|
"meta": {"set": 17},
|
||||||
|
"static": {
|
||||||
|
**STATIC,
|
||||||
|
"items": {"TFT_Item_InfinityEdge": {"effects": {"AD": 0.35, "CritChance": 35.0}}},
|
||||||
|
"augments": {},
|
||||||
|
},
|
||||||
|
"roles": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_unit_value():
|
def test_unit_value():
|
||||||
@@ -57,19 +64,30 @@ def test_score_ordering():
|
|||||||
assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT)
|
assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT)
|
||||||
|
|
||||||
|
|
||||||
def test_active_trait_beats_inactive():
|
|
||||||
pair = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
|
||||||
{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
|
||||||
solo_sum = score_board(pair[:1], [], ARTIFACT) + score_board(pair[1:], [], ARTIFACT)
|
|
||||||
assert score_board(pair, [], ARTIFACT) > solo_sum
|
|
||||||
|
|
||||||
|
|
||||||
def test_items_increase_score():
|
def test_items_increase_score():
|
||||||
bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||||
with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}]
|
with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}]
|
||||||
assert score_board(with_item, [], ARTIFACT) > score_board(bare, [], ARTIFACT)
|
assert score_board(with_item, [], ARTIFACT) > score_board(bare, [], ARTIFACT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_augments_ignored():
|
||||||
|
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||||
|
assert score_board(board, ["TFT17_Augment_X"], ARTIFACT) == \
|
||||||
|
score_board(board, [], ARTIFACT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_profiles_roles_and_unknown_units():
|
||||||
|
board = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
||||||
|
{"api_name": "TFT17_B", "stars": 1, "items": []},
|
||||||
|
{"api_name": "UNKNOWN", "stars": 1, "items": []}]
|
||||||
|
profiles = board_profiles(board, ARTIFACT)
|
||||||
|
assert len(profiles) == 2 # unbekannte Units fallen raus
|
||||||
|
tank, carry = profiles
|
||||||
|
assert tank["defensive"] and not carry["defensive"]
|
||||||
|
assert tank["def"] > carry["def"]
|
||||||
|
assert carry["off"] > tank["off"]
|
||||||
|
|
||||||
|
|
||||||
def test_spearman():
|
def test_spearman():
|
||||||
assert spearman([1, 2, 3, 4], [10, 20, 30, 40]) == pytest.approx(1.0)
|
assert spearman([1, 2, 3, 4], [10, 20, 30, 40]) == pytest.approx(1.0)
|
||||||
assert spearman([1, 2, 3, 4], [40, 30, 20, 10]) == pytest.approx(-1.0)
|
assert spearman([1, 2, 3, 4], [40, 30, 20, 10]) == pytest.approx(-1.0)
|
||||||
@@ -88,39 +106,7 @@ def test_craftable_item_pool():
|
|||||||
assert craftable_items(items) == ["TFT_Item_InfinityEdge"]
|
assert craftable_items(items) == ["TFT_Item_InfinityEdge"]
|
||||||
|
|
||||||
|
|
||||||
def test_stat_mults_rank_within_cost():
|
def _built_artifact():
|
||||||
from tft.model.baseline import compute_stat_mults
|
|
||||||
|
|
||||||
def unit(hp, armor, ad, aspd):
|
|
||||||
return {"cost": 2, "traits": [], "role": "Tank",
|
|
||||||
"stats": {"hp": hp, "armor": armor, "magicResist": armor,
|
|
||||||
"damage": ad, "attackSpeed": aspd, "mana": 60,
|
|
||||||
"initialMana": 0, "range": 1}}
|
|
||||||
|
|
||||||
units = {"tanky": unit(900, 60, 45, 0.6), "avg": unit(700, 40, 55, 0.7),
|
|
||||||
"weak": unit(550, 25, 45, 0.6)}
|
|
||||||
mults = compute_stat_mults(units)
|
|
||||||
assert mults["tanky"] > mults["avg"] > mults["weak"]
|
|
||||||
assert all(0.9 <= m <= 1.1 for m in mults.values())
|
|
||||||
|
|
||||||
|
|
||||||
def test_stat_mults_identical_units_are_neutral():
|
|
||||||
from tft.model.baseline import compute_stat_mults
|
|
||||||
from tests.test_sim import make_static
|
|
||||||
|
|
||||||
mults = compute_stat_mults(make_static()["units"])
|
|
||||||
assert all(m == 1.0 for m in mults.values())
|
|
||||||
|
|
||||||
|
|
||||||
def test_stat_mult_raises_score_legacy():
|
|
||||||
from tft.model.score import score_board_legacy as score_board
|
|
||||||
|
|
||||||
art = {**ARTIFACT, "stat_mults": {"TFT17_B": 1.1}}
|
|
||||||
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
|
||||||
assert score_board(board, [], art) > score_board(board, [], ARTIFACT)
|
|
||||||
|
|
||||||
|
|
||||||
def _mech_artifact():
|
|
||||||
from tft.model import artifact as artifact_mod
|
from tft.model import artifact as artifact_mod
|
||||||
|
|
||||||
static = {
|
static = {
|
||||||
@@ -143,30 +129,18 @@ def _mech_artifact():
|
|||||||
return artifact_mod.build(static)
|
return artifact_mod.build(static)
|
||||||
|
|
||||||
|
|
||||||
def test_mechanical_balanced_beats_lopsided():
|
def test_balanced_beats_lopsided():
|
||||||
from tft.model.score import score_mechanical
|
art = _built_artifact()
|
||||||
art = _mech_artifact()
|
|
||||||
balanced = [{"api_name": "TANK", "stars": 1, "items": []},
|
balanced = [{"api_name": "TANK", "stars": 1, "items": []},
|
||||||
{"api_name": "CARRY", "stars": 1, "items": []}]
|
{"api_name": "CARRY", "stars": 1, "items": []}]
|
||||||
tanks = [{"api_name": "TANK", "stars": 1, "items": []} for _ in range(2)]
|
tanks = [{"api_name": "TANK", "stars": 1, "items": []} for _ in range(2)]
|
||||||
assert score_mechanical(balanced, [], art) > score_mechanical(tanks, [], art)
|
assert score_board(balanced, [], art) > score_board(tanks, [], art)
|
||||||
|
|
||||||
|
|
||||||
def test_mechanical_items_and_stars_raise_strength():
|
def test_items_and_stars_raise_strength():
|
||||||
from tft.model.score import score_mechanical
|
art = _built_artifact()
|
||||||
art = _mech_artifact()
|
|
||||||
base = [{"api_name": "CARRY", "stars": 1, "items": []}]
|
base = [{"api_name": "CARRY", "stars": 1, "items": []}]
|
||||||
starred = [{"api_name": "CARRY", "stars": 2, "items": []}]
|
starred = [{"api_name": "CARRY", "stars": 2, "items": []}]
|
||||||
equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}]
|
equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}]
|
||||||
assert score_mechanical(starred, [], art) > score_mechanical(base, [], art)
|
assert score_board(starred, [], art) > score_board(base, [], art)
|
||||||
assert score_mechanical(equipped, [], art) > score_mechanical(base, [], art)
|
assert score_board(equipped, [], art) > score_board(base, [], art)
|
||||||
|
|
||||||
|
|
||||||
def test_mechanical_recognized_trait_buffs_stats():
|
|
||||||
from tft.model.score import score_mechanical
|
|
||||||
art = _mech_artifact()
|
|
||||||
pair = [{"api_name": "TANK", "stars": 1, "items": []},
|
|
||||||
{"api_name": "CARRY", "stars": 1, "items": []}]
|
|
||||||
solo_sum = (score_mechanical(pair[:1], [], art)
|
|
||||||
+ score_mechanical(pair[1:], [], art))
|
|
||||||
assert score_mechanical(pair, [], art) > solo_sum
|
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ def test_items_change_profile():
|
|||||||
assert not statsheet.item_is_modeled(ITEMS["unmodeled"])
|
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():
|
def test_frontline_casts_more():
|
||||||
spell = [0, 300, 450, 700, 0, 0, 0]
|
spell = [0, 300, 450, 700, 0, 0, 0]
|
||||||
tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline")
|
tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline")
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ def serialize(game: Game) -> dict:
|
|||||||
"traits": traits_panel,
|
"traits": traits_panel,
|
||||||
"augment_offer": [
|
"augment_offer": [
|
||||||
{"api_name": a, "name": static["augments"].get(a, {}).get("name", a),
|
{"api_name": a, "name": static["augments"].get(a, {}).get("name", a),
|
||||||
|
"desc": static["augments"].get(a, {}).get("desc", ""),
|
||||||
|
"tier": static["augments"].get(a, {}).get("tier"),
|
||||||
"icon": static["augments"].get(a, {}).get("icon")}
|
"icon": static["augments"].get(a, {}).get("icon")}
|
||||||
for a in game.augment_offer
|
for a in game.augment_offer
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ def main() -> None:
|
|||||||
|
|
||||||
sub.add_parser("extract", help="extract endboards from raw matches")
|
sub.add_parser("extract", help="extract endboards from raw matches")
|
||||||
sub.add_parser("build-artifact", help="build analysis.json from static data + endboards")
|
sub.add_parser("build-artifact", help="build analysis.json from static data + endboards")
|
||||||
p_cal = sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
|
sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
|
||||||
p_cal.add_argument("--compare", action="store_true",
|
|
||||||
help="A/B legacy vs. mechanical scorer + tau-Fit")
|
|
||||||
|
|
||||||
p_auto = sub.add_parser("autoplay", help="run scripted games headless")
|
p_auto = sub.add_parser("autoplay", help="run scripted games headless")
|
||||||
p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ")
|
p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ")
|
||||||
@@ -83,28 +81,13 @@ def main() -> None:
|
|||||||
elif args.command == "calibrate":
|
elif args.command == "calibrate":
|
||||||
from tft import db
|
from tft import db
|
||||||
from tft.model import artifact as artifact_mod
|
from tft.model import artifact as artifact_mod
|
||||||
from tft.model.calibrate import calibrate, fit_tau
|
from tft.model.calibrate import calibrate
|
||||||
from tft.model.score import score_board_legacy, score_mechanical
|
|
||||||
from tft.staticdata.fetch import load_static
|
|
||||||
|
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
learned_art = artifact_mod.load(current_set())
|
result = calibrate(conn, artifact_mod.load(current_set()), holdout_only=True)
|
||||||
baseline_art = artifact_mod.build(load_static(), {})
|
|
||||||
r_base = calibrate(conn, baseline_art, holdout_only=True)
|
|
||||||
r_learned = calibrate(conn, learned_art, holdout_only=True)
|
|
||||||
print(f"holdout matches: {r_learned['matches']}")
|
|
||||||
print(f"spearman baseline: {r_base['mean_spearman']:.3f}")
|
|
||||||
print(f"spearman learned: {r_learned['mean_spearman']:.3f}")
|
|
||||||
if args.compare:
|
|
||||||
r_leg = calibrate(conn, learned_art, holdout_only=True, score_fn=score_board_legacy)
|
|
||||||
r_mech = calibrate(conn, learned_art, holdout_only=True, score_fn=score_mechanical)
|
|
||||||
delta = r_mech["mean_spearman"] - r_leg["mean_spearman"]
|
|
||||||
tau = fit_tau(conn, learned_art, score_mechanical)
|
|
||||||
print(f"A/B legacy: {r_leg['mean_spearman']:.3f}")
|
|
||||||
print(f"A/B mechanical: {r_mech['mean_spearman']:.3f} (delta {delta:+.3f})")
|
|
||||||
print(f"tau-fit (mechanical): {tau['tau']} über {tau['pairs']} Paare, "
|
|
||||||
f"log-loss {tau.get('log_loss')}")
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
print(f"holdout matches: {result['matches']}")
|
||||||
|
print(f"spearman: {result['mean_spearman']:.3f}")
|
||||||
|
|
||||||
elif args.command == "autoplay":
|
elif args.command == "autoplay":
|
||||||
from tft.constants.loader import load_constants
|
from tft.constants.loader import load_constants
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ stage1 = ["carousel", "pve", "pve", "pve"]
|
|||||||
stage_n = ["pvp", "pvp", "pvp", "carousel", "pvp", "pvp", "pve"]
|
stage_n = ["pvp", "pvp", "pvp", "carousel", "pvp", "pvp", "pve"]
|
||||||
augment_rounds = ["2-1", "3-2", "4-2"]
|
augment_rounds = ["2-1", "3-2", "4-2"]
|
||||||
|
|
||||||
|
[augments]
|
||||||
|
# Tier-Gewichte [Silber, Gold, Prismatisch] pro Augment-Runde.
|
||||||
|
# Alle drei Angebote einer Runde haben dieselbe Stufe (wie im Spiel).
|
||||||
|
tier_weights = { "2-1" = [50, 45, 5], "3-2" = [35, 50, 15], "4-2" = [20, 55, 25] }
|
||||||
|
|
||||||
[loot]
|
[loot]
|
||||||
# Vereinfachtes Loot: PvE-Drops als [components, gold].
|
# Vereinfachtes Loot: PvE-Drops als [components, gold].
|
||||||
stage1_pve = [1, 1] # pro Stage-1-PvE-Runde
|
stage1_pve = [1, 1] # pro Stage-1-PvE-Runde
|
||||||
@@ -75,7 +80,6 @@ hp_multiplier = 1.8
|
|||||||
ad_multiplier = 1.5
|
ad_multiplier = 1.5
|
||||||
|
|
||||||
[combat]
|
[combat]
|
||||||
# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)).
|
# Deterministische DPS-Simulation mit 60 FPS; Zeitlimit 30 s.
|
||||||
# tau = 0.28: Log-Loss-Fit über 1064 Holdout-Platzierungspaare (mechanical Scorer).
|
# Bei Ablauf gewinnt die Seite mit mehr verbleibender Gesamt-Defense.
|
||||||
tau = 0.28
|
max_frames = 1800
|
||||||
variance = 0.08
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ SPATULA_COMPONENTS = {"TFT_Item_Spatula", "TFT_Item_FryingPan"}
|
|||||||
|
|
||||||
|
|
||||||
def craftable_items(static_items: dict) -> list[str]:
|
def craftable_items(static_items: dict) -> list[str]:
|
||||||
"""Standard-kombinierbare Items — der Pool für Loot und Bot-Boards."""
|
"""Standard-kombinierbare Items — Basis für Rezepte und Komponenten-Pool."""
|
||||||
pool = []
|
pool = []
|
||||||
for api, item in static_items.items():
|
for api, item in static_items.items():
|
||||||
comp = item.get("composition") or []
|
comp = item.get("composition") or []
|
||||||
@@ -26,27 +26,51 @@ def craftable_items(static_items: dict) -> list[str]:
|
|||||||
return sorted(pool)
|
return sorted(pool)
|
||||||
|
|
||||||
|
|
||||||
def tier_profiles(static: dict, roles: dict) -> dict:
|
def recipes(static_items: dict) -> dict:
|
||||||
"""Mittleres Mechanik-Profil (eHP/DPS, 1★, itemlos) pro Kostenstufe.
|
"""'CompA|CompB' (sortiert) -> fertiges Item."""
|
||||||
|
return {
|
||||||
|
"|".join(sorted(static_items[api]["composition"])): api
|
||||||
|
for api in craftable_items(static_items)
|
||||||
|
}
|
||||||
|
|
||||||
Anker für den mechanischen Scorer: Riot balanciert um die Kosten,
|
|
||||||
die Mechanik differenziert innerhalb der Stufe.
|
def component_pool(static_items: dict) -> list[str]:
|
||||||
|
"""Die Basis-Komponenten — das, was Loot tatsächlich droppt.
|
||||||
|
|
||||||
|
Echte Komponenten stecken in vielen Rezepten; Sondervarianten
|
||||||
|
(z.B. CursedBlade) nur in einzelnen.
|
||||||
|
"""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for api in craftable_items(static_items):
|
||||||
|
for comp in static_items[api]["composition"]:
|
||||||
|
counts[comp] = counts.get(comp, 0) + 1
|
||||||
|
return sorted(c for c, n in counts.items() if n >= 3)
|
||||||
|
|
||||||
|
|
||||||
|
def spell_dps_cap(static: dict, roles: dict) -> float | None:
|
||||||
|
"""90. Perzentil der Spell-DPS aller Units (1★, itemlos).
|
||||||
|
|
||||||
|
Dient im Score als Floor des relativen Spell-Deckels — rettet Caster
|
||||||
|
ohne Auto-Schaden (siehe statsheet.unit_stats).
|
||||||
"""
|
"""
|
||||||
from tft.model import statsheet
|
from tft.model import statsheet
|
||||||
|
|
||||||
by_cost: dict[int, list] = {}
|
values = sorted(
|
||||||
for api, unit in static["units"].items():
|
p["spell_dps"]
|
||||||
profile = statsheet.unit_stats(
|
for api, unit in static["units"].items()
|
||||||
|
if (p := statsheet.unit_stats(
|
||||||
unit, 1, [], static["items"], None, roles.get(api)
|
unit, 1, [], static["items"], None, roles.get(api)
|
||||||
)
|
))["spell_dps"] > 0
|
||||||
by_cost.setdefault(unit["cost"], []).append(profile)
|
)
|
||||||
return {
|
if not values:
|
||||||
str(cost): {
|
return None
|
||||||
"ehp": sum(p["ehp"] for p in profiles) / len(profiles),
|
return values[int(len(values) * 0.9)]
|
||||||
"dps": max(sum(p["dps"] for p in profiles) / len(profiles), 1.0),
|
|
||||||
}
|
|
||||||
for cost, profiles in by_cost.items()
|
def _augment_specs(static: dict) -> dict:
|
||||||
}
|
from tft.model.augments import build_specs
|
||||||
|
|
||||||
|
return build_specs(static["augments"], static["units"])
|
||||||
|
|
||||||
|
|
||||||
def build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict:
|
def build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict:
|
||||||
@@ -68,9 +92,14 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N
|
|||||||
"augments": static["augments"],
|
"augments": static["augments"],
|
||||||
},
|
},
|
||||||
"roles": roles,
|
"roles": roles,
|
||||||
"stat_mults": baseline.compute_stat_mults(static["units"]),
|
"spell_dps_cap": spell_dps_cap(static, roles),
|
||||||
"tier_profiles": tier_profiles(static, roles),
|
|
||||||
"item_pool": craftable_items(static["items"]),
|
"item_pool": craftable_items(static["items"]),
|
||||||
|
"component_pool": component_pool(static["items"]),
|
||||||
|
"recipes": recipes(static["items"]),
|
||||||
|
"augment_specs": _augment_specs(static),
|
||||||
|
# Riot-Match-API liefert keine Augments (Feld entfernt) — leer heißt:
|
||||||
|
# der Sim nutzt alle geparsten Augments als Pool.
|
||||||
|
"augment_pool": [],
|
||||||
"learned": learned or {},
|
"learned": learned or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
backend/tft/model/augments.py
Normal file
121
backend/tft/model/augments.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Augment-Atome: mechanisch anwendbare Effekte aus effects-Keys und Beschreibung.
|
||||||
|
|
||||||
|
Deterministische Atome (~200/432): Gold, XP, Komponenten, Units, Team-Buffs,
|
||||||
|
Spieler-HP. Bedingte und Kampf-Spezialeffekte bleiben beim gelernten Fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from tft.model.statsheet import _fraction
|
||||||
|
|
||||||
|
GOLD_KEYS = ("Gold", "InstantGold", "GoldNow", "GoldAmount")
|
||||||
|
XP_KEYS = ("XP", "InstantXP", "Experience", "UpfrontXP")
|
||||||
|
COMPONENT_KEYS = ("NumComponents", "ComponentsToGive")
|
||||||
|
ANVIL_KEYS = ("CompletedAnvils",)
|
||||||
|
PLAYER_HP_KEYS = ("PlayerHealth", "Heal", "Health")
|
||||||
|
|
||||||
|
# effects-Key -> (Accumulator-Stat, Normalisierung)
|
||||||
|
STAT_KEYS = {
|
||||||
|
"AD": ("ad_pct", "frac"), "BonusAD": ("ad_pct", "frac"), "ADBonus": ("ad_pct", "frac"),
|
||||||
|
"AP": ("ap_flat", "flat"), "AbilityPower": ("ap_flat", "flat"), "BonusAP": ("ap_flat", "flat"),
|
||||||
|
"AS": ("as_pct", "frac"), "AttackSpeed": ("as_pct", "frac"),
|
||||||
|
"BonusAS": ("as_pct", "frac"), "TeamAttackSpeed": ("as_pct", "frac"),
|
||||||
|
"Health": ("hp_flat", "flat"), "BaseHP": ("hp_flat", "flat"), "BonusHealth": ("hp_flat", "flat"),
|
||||||
|
"Armor": ("armor_flat", "flat"), "BonusMR": ("mr_flat", "flat"),
|
||||||
|
"DamageAmp": ("damage_amp", "frac"), "Durability": ("dr", "frac"),
|
||||||
|
"Omnivamp": ("dr", "frac_half"), "CritChance": ("crit_chance", "frac"),
|
||||||
|
}
|
||||||
|
|
||||||
|
TEAM_RE = re.compile(r"(your team|allies|units|champions|army) gain", re.I)
|
||||||
|
CONDITIONAL_RE = re.compile(
|
||||||
|
r"per |for each|each level|per level|combat start|when|whenever|casting"
|
||||||
|
r"|(?:on|after) attack|takedown|dies|each round|after each", re.I)
|
||||||
|
GOLD_RE = re.compile(r"[Gg]ain (\d+) gold(?! each| per)")
|
||||||
|
GOLD_PER_STAGE_RE = re.compile(r"(\d+) gold (?:at the start of|each) (?:every )?stage", re.I)
|
||||||
|
XP_RE = re.compile(r"[Gg]ain (\d+) XP")
|
||||||
|
COMP_RE = re.compile(r"(\d+) random(?:\w| )*components?", re.I)
|
||||||
|
COST_UNITS_RE = re.compile(r"[Gg]ain (\d+) (\d)-cost champions?")
|
||||||
|
TACTICIAN_HP_RE = re.compile(r"[Gg]ain (\d+) Tactician Health")
|
||||||
|
|
||||||
|
|
||||||
|
def _num(effects: dict, keys: tuple) -> float | None:
|
||||||
|
for key in keys:
|
||||||
|
val = effects.get(key)
|
||||||
|
if isinstance(val, (int, float)) and val > 0:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_spec(aug: dict, static_units: dict) -> dict:
|
||||||
|
desc = aug.get("desc") or ""
|
||||||
|
effects = aug.get("effects") or {}
|
||||||
|
atoms = []
|
||||||
|
|
||||||
|
offerable = len(desc) >= 10 and "_Desc" not in desc and "@" not in desc
|
||||||
|
if not offerable:
|
||||||
|
return {"atoms": [], "offerable": False}
|
||||||
|
|
||||||
|
if TACTICIAN_HP_RE.search(desc):
|
||||||
|
m = TACTICIAN_HP_RE.search(desc)
|
||||||
|
atoms.append({"kind": "player_hp",
|
||||||
|
"amount": int(_num(effects, PLAYER_HP_KEYS) or m.group(1))})
|
||||||
|
|
||||||
|
gold = _num(effects, GOLD_KEYS)
|
||||||
|
m = GOLD_RE.search(desc)
|
||||||
|
if gold is None and m:
|
||||||
|
gold = int(m.group(1))
|
||||||
|
if gold:
|
||||||
|
atoms.append({"kind": "gold_now", "amount": int(gold)})
|
||||||
|
|
||||||
|
m = GOLD_PER_STAGE_RE.search(desc)
|
||||||
|
if m:
|
||||||
|
atoms.append({"kind": "gold_per_stage", "amount": int(m.group(1))})
|
||||||
|
|
||||||
|
xp = _num(effects, XP_KEYS)
|
||||||
|
m = XP_RE.search(desc)
|
||||||
|
if xp is None and m:
|
||||||
|
xp = int(m.group(1))
|
||||||
|
if xp:
|
||||||
|
kind = "xp_per_round" if "each round" in desc.lower() else "xp_now"
|
||||||
|
atoms.append({"kind": kind, "amount": int(xp)})
|
||||||
|
|
||||||
|
comps = _num(effects, COMPONENT_KEYS)
|
||||||
|
m = COMP_RE.search(desc)
|
||||||
|
if comps is None and m:
|
||||||
|
comps = int(m.group(1))
|
||||||
|
if comps:
|
||||||
|
atoms.append({"kind": "components", "count": int(comps)})
|
||||||
|
|
||||||
|
anvils = _num(effects, ANVIL_KEYS)
|
||||||
|
if anvils:
|
||||||
|
atoms.append({"kind": "completed_items", "count": int(anvils)})
|
||||||
|
|
||||||
|
# Benannte Unit-Geschenke: "Gain a Briar, a Jinx, ..."
|
||||||
|
if re.search(r"[Gg]ain (a|an) [A-Z]", desc):
|
||||||
|
granted = [api for api, u in static_units.items()
|
||||||
|
if u.get("name") and re.search(rf"[Gg]ain (?:a|an) {re.escape(u['name'])}\b", desc)]
|
||||||
|
if granted:
|
||||||
|
atoms.append({"kind": "unit_grant", "units": sorted(granted)})
|
||||||
|
m = COST_UNITS_RE.search(desc)
|
||||||
|
if m:
|
||||||
|
atoms.append({"kind": "unit_grant", "cost": int(m.group(2)), "count": int(m.group(1))})
|
||||||
|
|
||||||
|
# Team-Stat-Buffs nur bei flachem "your team gains ..." ohne Bedingung.
|
||||||
|
if TEAM_RE.search(desc) and not CONDITIONAL_RE.search(desc):
|
||||||
|
for key, (stat, norm) in STAT_KEYS.items():
|
||||||
|
val = effects.get(key)
|
||||||
|
if not isinstance(val, (int, float)) or val == 0:
|
||||||
|
continue
|
||||||
|
if norm == "frac":
|
||||||
|
value = _fraction(val)
|
||||||
|
elif norm == "frac_half":
|
||||||
|
value = _fraction(val) * 0.5
|
||||||
|
else:
|
||||||
|
value = val
|
||||||
|
atoms.append({"kind": "team_buff", "stat": stat, "value": value})
|
||||||
|
|
||||||
|
return {"atoms": atoms, "offerable": True}
|
||||||
|
|
||||||
|
|
||||||
|
def build_specs(static_augments: dict, static_units: dict) -> dict:
|
||||||
|
return {api: build_spec(aug, static_units) for api, aug in static_augments.items()}
|
||||||
@@ -1,56 +1,10 @@
|
|||||||
"""Rule-based baseline: unit values, stat proxies, role classification."""
|
"""Rule-based baseline: unit values and role classification."""
|
||||||
|
|
||||||
|
|
||||||
def unit_value(cost: int, stars: int) -> float:
|
def unit_value(cost: int, stars: int) -> float:
|
||||||
return cost * 3 ** (stars - 1)
|
return cost * 3 ** (stars - 1)
|
||||||
|
|
||||||
|
|
||||||
def stat_proxies(stats: dict) -> dict:
|
|
||||||
# cdragon liefert für manche Units null-Werte — als 0 behandeln.
|
|
||||||
hp = stats.get("hp") or 0
|
|
||||||
resists = ((stats.get("armor") or 0) + (stats.get("magicResist") or 0)) / 2
|
|
||||||
ad = stats.get("damage") or 0
|
|
||||||
aspd = stats.get("attackSpeed") or 0
|
|
||||||
mana_gap = max((stats.get("mana") or 0) - (stats.get("initialMana") or 0), 1)
|
|
||||||
return {
|
|
||||||
"ehp": hp * (1 + resists / 100),
|
|
||||||
"auto_dps": ad * aspd,
|
|
||||||
"cast_rate": aspd * 10 / mana_gap,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
STAT_MULT_RANGE = (0.9, 1.1)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_stat_mults(static_units: dict) -> dict:
|
|
||||||
"""Stat-Stärke relativ zur eigenen Kostenstufe: (eHP + Offense) / 2, gekappt.
|
|
||||||
|
|
||||||
Offense = Maximum aus Auto-DPS und Cast-Rate (je normalisiert), damit
|
|
||||||
Caster nicht gegen Auto-Attacker abfallen.
|
|
||||||
"""
|
|
||||||
proxies = {api: stat_proxies(u["stats"]) for api, u in static_units.items()}
|
|
||||||
by_cost: dict[int, list[str]] = {}
|
|
||||||
for api, u in static_units.items():
|
|
||||||
by_cost.setdefault(u["cost"], []).append(api)
|
|
||||||
|
|
||||||
mults = {}
|
|
||||||
for apis in by_cost.values():
|
|
||||||
n = len(apis)
|
|
||||||
avg = {
|
|
||||||
key: sum(proxies[a][key] for a in apis) / n or 1.0
|
|
||||||
for key in ("ehp", "auto_dps", "cast_rate")
|
|
||||||
}
|
|
||||||
for a in apis:
|
|
||||||
ehp_norm = proxies[a]["ehp"] / avg["ehp"]
|
|
||||||
offense = max(
|
|
||||||
proxies[a]["auto_dps"] / avg["auto_dps"],
|
|
||||||
proxies[a]["cast_rate"] / avg["cast_rate"],
|
|
||||||
)
|
|
||||||
raw = (ehp_norm + offense) / 2
|
|
||||||
mults[a] = round(min(max(raw, STAT_MULT_RANGE[0]), STAT_MULT_RANGE[1]), 4)
|
|
||||||
return mults
|
|
||||||
|
|
||||||
|
|
||||||
def classify_role(unit: dict) -> str:
|
def classify_role(unit: dict) -> str:
|
||||||
"""frontline | ad_carry | ap_carry | utility, from cdragon role with stat fallback."""
|
"""frontline | ad_carry | ap_carry | utility, from cdragon role with stat fallback."""
|
||||||
role = (unit.get("role") or "").lower()
|
role = (unit.get("role") or "").lower()
|
||||||
@@ -64,19 +18,3 @@ def classify_role(unit: dict) -> str:
|
|||||||
return "utility"
|
return "utility"
|
||||||
stats = unit["stats"]
|
stats = unit["stats"]
|
||||||
return "frontline" if stats["range"] <= 1 else "ad_carry"
|
return "frontline" if stats["range"] <= 1 else "ad_carry"
|
||||||
|
|
||||||
|
|
||||||
# Default multipliers, replaced by learned values when the artifact has them.
|
|
||||||
DEFAULT_ITEM_MULT = 1.15
|
|
||||||
ROLE_FIT_BONUS = 1.05
|
|
||||||
DEFAULT_TRAIT_TIER_MULT = [1.0, 1.03, 1.07, 1.12, 1.20] # index = reached breakpoint ordinal
|
|
||||||
|
|
||||||
# Item tags that fit a role (checked against item api_name, crude but stable).
|
|
||||||
AD_HINTS = ("Deathblade", "InfinityEdge", "GiantSlayer", "LastWhisper", "RunaansHurricane", "GuinsoosRageblade")
|
|
||||||
AP_HINTS = ("RabadonsDeathcap", "ArchangelsStaff", "JeweledGauntlet", "HextechGunblade", "NashorsTooth", "Morellonomicon")
|
|
||||||
TANK_HINTS = ("BrambleVest", "DragonsClaw", "WarmogsArmor", "GargoyleStoneplate", "Redemption", "SunfireCape")
|
|
||||||
|
|
||||||
|
|
||||||
def item_fits_role(item_api_name: str, role: str) -> bool:
|
|
||||||
hints = {"ad_carry": AD_HINTS, "ap_carry": AP_HINTS, "frontline": TANK_HINTS}.get(role, ())
|
|
||||||
return any(h in item_api_name for h in hints)
|
|
||||||
|
|||||||
@@ -81,38 +81,3 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False, score_fn=None) -
|
|||||||
"matches": n,
|
"matches": n,
|
||||||
"mean_spearman": sum(correlations) / n if n else 0.0,
|
"mean_spearman": sum(correlations) / n if n else 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def fit_tau(conn, artifact: dict, score_fn) -> dict:
|
|
||||||
"""Tau per Log-Loss über alle Platzierungspaare der Holdout-Matches fitten."""
|
|
||||||
import math
|
|
||||||
|
|
||||||
by_match = _holdout_scores(conn, artifact, True, score_fn)
|
|
||||||
pairs = []
|
|
||||||
for players in by_match.values():
|
|
||||||
if len(players) < 8:
|
|
||||||
continue
|
|
||||||
for i in range(len(players)):
|
|
||||||
for j in range(i + 1, len(players)):
|
|
||||||
(pl_a, s_a), (pl_b, s_b) = players[i], players[j]
|
|
||||||
mean = (s_a + s_b) / 2
|
|
||||||
if mean <= 0:
|
|
||||||
continue
|
|
||||||
d = (s_a - s_b) / mean
|
|
||||||
pairs.append((d, pl_a < pl_b)) # kleinere Platzierung = besser
|
|
||||||
|
|
||||||
if not pairs:
|
|
||||||
return {"tau": None, "pairs": 0}
|
|
||||||
|
|
||||||
def log_loss(tau: float) -> float:
|
|
||||||
eps = 1e-9
|
|
||||||
total = 0.0
|
|
||||||
for d, a_wins in pairs:
|
|
||||||
p = 1 / (1 + math.exp(-d / tau))
|
|
||||||
p = min(max(p, eps), 1 - eps)
|
|
||||||
total += -math.log(p if a_wins else 1 - p)
|
|
||||||
return total / len(pairs)
|
|
||||||
|
|
||||||
taus = [t / 100 for t in range(2, 51, 2)]
|
|
||||||
best = min(taus, key=log_loss)
|
|
||||||
return {"tau": best, "pairs": len(pairs), "log_loss": round(log_loss(best), 4)}
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this."""
|
"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this.
|
||||||
|
|
||||||
|
Off/Def-Modell: Off = Auto- + Spell-DPS, Def = eHP — pro Unit aus exakten
|
||||||
|
cdragon-Werten (Sterne und Items eingerechnet). Traits und Augments werden
|
||||||
|
bewusst ignoriert (spätere Ausbaustufe); active_trait_tiers bleibt fürs UI.
|
||||||
|
"""
|
||||||
|
|
||||||
from tft.model import baseline, statsheet
|
from tft.model import baseline, statsheet
|
||||||
|
|
||||||
@@ -27,126 +32,34 @@ def active_trait_tiers(board_units: list[dict], static_traits: dict, static_unit
|
|||||||
return tiers
|
return tiers
|
||||||
|
|
||||||
|
|
||||||
def _apply_shared_multipliers(total: float, board_units: list[dict], augments: list[str],
|
def unit_profile(u: dict, artifact: dict) -> dict | None:
|
||||||
learned: dict) -> float:
|
"""Kampfprofil einer Board-Unit: {"off", "def", "defensive", "unit"}.
|
||||||
augment_mults = learned.get("augments", {})
|
|
||||||
for augment in augments:
|
|
||||||
total *= augment_mults.get(augment, 1.0)
|
|
||||||
|
|
||||||
pair_lifts = learned.get("pairs", {})
|
"defensive" steuert die Zielreihenfolge im Kampf: Frontline/Utility
|
||||||
names = sorted({u["api_name"] for u in board_units})
|
sterben zuerst, Carries zuletzt.
|
||||||
lift_sum = sum(
|
"""
|
||||||
pair_lifts.get(f"{a}|{b}", 0.0)
|
unit = artifact["static"]["units"].get(u["api_name"])
|
||||||
for i, a in enumerate(names)
|
if not unit:
|
||||||
for b in names[i + 1 :]
|
return None
|
||||||
|
role = artifact.get("roles", {}).get(u["api_name"]) or baseline.classify_role(unit)
|
||||||
|
# role=None: der Frontline-Cast-Bonus verschlechtert die Holdout-Korrelation
|
||||||
|
# (0.659 vs. 0.679) — die Rolle steuert nur die Zielreihenfolge im Kampf.
|
||||||
|
stats = statsheet.unit_stats(
|
||||||
|
unit, u["stars"], u.get("items", []), artifact["static"]["items"],
|
||||||
|
None, None, spell_cap=artifact.get("spell_dps_cap"),
|
||||||
)
|
)
|
||||||
return total * (1 + min(max(lift_sum * 0.01, -0.10), 0.10))
|
return {"off": stats["dps"], "def": stats["ehp"],
|
||||||
|
"defensive": role in ("frontline", "utility"), "unit": u}
|
||||||
|
|
||||||
|
|
||||||
def score_board_legacy(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
def board_profiles(board_units: list[dict], artifact: dict) -> list[dict]:
|
||||||
"""Heuristik-Scorer: Kostenwert × Multiplikatoren."""
|
return [p for u in board_units if (p := unit_profile(u, artifact))]
|
||||||
static_units = artifact["static"]["units"]
|
|
||||||
static_traits = artifact["static"]["traits"]
|
|
||||||
learned = artifact.get("learned", {})
|
|
||||||
unit_mults = learned.get("units", {})
|
|
||||||
item_mults = learned.get("items", {})
|
|
||||||
trait_mults = learned.get("traits", {})
|
|
||||||
roles = artifact.get("roles", {})
|
|
||||||
stat_mults = artifact.get("stat_mults", {})
|
|
||||||
|
|
||||||
total = 0.0
|
|
||||||
for u in board_units:
|
|
||||||
unit = static_units.get(u["api_name"])
|
|
||||||
if not unit:
|
|
||||||
continue
|
|
||||||
value = (
|
|
||||||
baseline.unit_value(unit["cost"], u["stars"])
|
|
||||||
* stat_mults.get(u["api_name"], 1.0)
|
|
||||||
* unit_mults.get(u["api_name"], 1.0)
|
|
||||||
)
|
|
||||||
role = roles.get(u["api_name"]) or baseline.classify_role(unit)
|
|
||||||
for item in u.get("items", []):
|
|
||||||
mult = item_mults.get(item, baseline.DEFAULT_ITEM_MULT)
|
|
||||||
if baseline.item_fits_role(item, role):
|
|
||||||
mult *= baseline.ROLE_FIT_BONUS
|
|
||||||
value *= mult
|
|
||||||
total += value
|
|
||||||
|
|
||||||
for trait, ordinal in active_trait_tiers(board_units, static_traits, static_units).items():
|
|
||||||
default = baseline.DEFAULT_TRAIT_TIER_MULT[
|
|
||||||
min(ordinal, len(baseline.DEFAULT_TRAIT_TIER_MULT) - 1)
|
|
||||||
]
|
|
||||||
total *= trait_mults.get(f"{trait}@{ordinal}", default)
|
|
||||||
|
|
||||||
return _apply_shared_multipliers(total, board_units, augments, learned)
|
|
||||||
|
|
||||||
|
|
||||||
def score_mechanical(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||||
"""Kampf-Approximation: strength = sqrt(Σ eHP × Σ DPS) aus exakten Stats."""
|
"""sqrt(Σoff × Σdef). augments bleibt nur für Signatur-Kompatibilität."""
|
||||||
static_units = artifact["static"]["units"]
|
profiles = board_profiles(board_units, artifact)
|
||||||
static_items = artifact["static"]["items"]
|
off = sum(p["off"] for p in profiles)
|
||||||
static_traits = artifact["static"]["traits"]
|
dfn = sum(p["def"] for p in profiles)
|
||||||
learned = artifact.get("learned", {})
|
# /100: nur Anzeige-Skalierung, für Vergleiche irrelevant.
|
||||||
unit_mults = learned.get("units", {})
|
return (off * dfn) ** 0.5 / 100
|
||||||
item_mults = learned.get("items", {})
|
|
||||||
trait_mults = learned.get("traits", {})
|
|
||||||
roles = artifact.get("roles", {})
|
|
||||||
|
|
||||||
tiers = active_trait_tiers(board_units, static_traits, static_units)
|
|
||||||
team_buffs, recognized = statsheet.trait_buffs(tiers, static_traits)
|
|
||||||
tier_profiles = artifact.get("tier_profiles", {})
|
|
||||||
|
|
||||||
# Anker: 1★ itemlos zählt exakt "Kosten" in beiden Dimensionen.
|
|
||||||
# Mechanik (Stats, Items, Trait-Buffs, Sterne) verschiebt relativ dazu;
|
|
||||||
# Extraktions-Ausreißer werden pro Stufe gekappt.
|
|
||||||
RATIO_CAP = (0.5, 2.0)
|
|
||||||
|
|
||||||
total_ehp = 0.0
|
|
||||||
total_dps = 0.0
|
|
||||||
for u in board_units:
|
|
||||||
unit = static_units.get(u["api_name"])
|
|
||||||
if not unit:
|
|
||||||
continue
|
|
||||||
profile = statsheet.unit_stats(
|
|
||||||
unit, u["stars"], u.get("items", []), static_items,
|
|
||||||
team_buffs, roles.get(u["api_name"]),
|
|
||||||
)
|
|
||||||
ref = tier_profiles.get(str(unit["cost"]))
|
|
||||||
base = baseline.unit_value(unit["cost"], u["stars"])
|
|
||||||
if ref:
|
|
||||||
star_ehp = statsheet.HP_STAR_MULT ** (u["stars"] - 1)
|
|
||||||
star_dps = statsheet.AD_STAR_MULT ** (u["stars"] - 1)
|
|
||||||
r_ehp = profile["ehp"] / (ref["ehp"] * star_ehp)
|
|
||||||
r_dps = profile["dps"] / (ref["dps"] * star_dps)
|
|
||||||
r_ehp = min(max(r_ehp, RATIO_CAP[0]), RATIO_CAP[1])
|
|
||||||
r_dps = min(max(r_dps, RATIO_CAP[0]), RATIO_CAP[1])
|
|
||||||
else:
|
|
||||||
r_ehp = r_dps = 1.0
|
|
||||||
mult = unit_mults.get(u["api_name"], 1.0)
|
|
||||||
total_ehp += mult * base * r_ehp
|
|
||||||
total_dps += mult * base * r_dps
|
|
||||||
|
|
||||||
strength = (total_ehp * total_dps) ** 0.5
|
|
||||||
|
|
||||||
for trait, ordinal in tiers.items():
|
|
||||||
key = f"{trait}@{ordinal}"
|
|
||||||
if trait in recognized:
|
|
||||||
# Buff steckt schon in den Stats — nur gelerntes Residuum.
|
|
||||||
strength *= trait_mults.get(key, 1.0)
|
|
||||||
else:
|
|
||||||
default = baseline.DEFAULT_TRAIT_TIER_MULT[
|
|
||||||
min(ordinal, len(baseline.DEFAULT_TRAIT_TIER_MULT) - 1)
|
|
||||||
]
|
|
||||||
strength *= trait_mults.get(key, default)
|
|
||||||
|
|
||||||
for u in board_units:
|
|
||||||
for item in u.get("items", []):
|
|
||||||
modeled = statsheet.item_is_modeled(static_items.get(item))
|
|
||||||
strength *= item_mults.get(item, 1.0 if modeled else baseline.DEFAULT_ITEM_MULT)
|
|
||||||
|
|
||||||
return _apply_shared_multipliers(strength, board_units, augments, learned)
|
|
||||||
|
|
||||||
|
|
||||||
# Aktiver Scorer: mechanical (A/B 23.07.: 0.724 vs. legacy 0.695 auf 38 Holdout-Matches).
|
|
||||||
# Legacy bleibt für `calibrate --compare` erhalten.
|
|
||||||
score_board = score_mechanical
|
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ MANA_PER_ATTACK = 10
|
|||||||
FRONTLINE_MANA_PER_SEC = 10
|
FRONTLINE_MANA_PER_SEC = 10
|
||||||
CAST_RATE_CAP = 1.5
|
CAST_RATE_CAP = 1.5
|
||||||
|
|
||||||
|
SPELL_AUTO_CAP = 3.0 # Spell-DPS max. 3× eigene Auto-DPS (Ausreißer-Guard)
|
||||||
|
|
||||||
MAPPED_ITEM_KEYS = ("AD", "AP", "AS", "CritChance", "Health", "Armor",
|
MAPPED_ITEM_KEYS = ("AD", "AP", "AS", "CritChance", "Health", "Armor",
|
||||||
"MagicResist", "ManaRegen")
|
"MagicResist", "ManaRegen", "BonusDamage", "DamageAmp",
|
||||||
|
"BonusPercentHP", "CritDamageToGive")
|
||||||
|
|
||||||
_TOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
_TOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
||||||
# Variablen mit diesen Tokens sind Mechanik-Parameter, keine Stat-Buffs.
|
# Variablen mit diesen Tokens sind Mechanik-Parameter, keine Stat-Buffs.
|
||||||
@@ -25,7 +28,8 @@ _SKIP_TOKENS = {"duration", "threshold", "rounds", "per", "num", "gold",
|
|||||||
def _empty_acc() -> dict:
|
def _empty_acc() -> dict:
|
||||||
return {"ad_pct": 0.0, "ap_flat": 0.0, "as_pct": 0.0, "hp_flat": 0.0,
|
return {"ad_pct": 0.0, "ap_flat": 0.0, "as_pct": 0.0, "hp_flat": 0.0,
|
||||||
"hp_pct": 0.0, "armor_flat": 0.0, "mr_flat": 0.0,
|
"hp_pct": 0.0, "armor_flat": 0.0, "mr_flat": 0.0,
|
||||||
"crit_chance": 0.0, "dr": 0.0, "mana_regen": 0.0, "damage_amp": 0.0}
|
"crit_chance": 0.0, "crit_dmg": 0.0, "dr": 0.0, "mana_regen": 0.0,
|
||||||
|
"damage_amp": 0.0}
|
||||||
|
|
||||||
|
|
||||||
def _apply_item_effects(item_apis: list[str], static_items: dict, acc: dict) -> None:
|
def _apply_item_effects(item_apis: list[str], static_items: dict, acc: dict) -> None:
|
||||||
@@ -50,6 +54,12 @@ def _apply_item_effects(item_apis: list[str], static_items: dict, acc: dict) ->
|
|||||||
acc["mr_flat"] += val
|
acc["mr_flat"] += val
|
||||||
elif key == "ManaRegen":
|
elif key == "ManaRegen":
|
||||||
acc["mana_regen"] += val
|
acc["mana_regen"] += val
|
||||||
|
elif key in ("BonusDamage", "DamageAmp"):
|
||||||
|
acc["damage_amp"] += _fraction(val)
|
||||||
|
elif key == "BonusPercentHP":
|
||||||
|
acc["hp_pct"] += _fraction(val)
|
||||||
|
elif key == "CritDamageToGive":
|
||||||
|
acc["crit_dmg"] += _fraction(val)
|
||||||
# alle übrigen Keys: bespoke Mechanik, bewusst ignoriert
|
# alle übrigen Keys: bespoke Mechanik, bewusst ignoriert
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +125,8 @@ def trait_buffs(active_tiers: dict, static_traits: dict) -> tuple[dict, set]:
|
|||||||
|
|
||||||
|
|
||||||
def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict,
|
def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict,
|
||||||
team_buffs: dict | None, role: str | None) -> dict:
|
team_buffs: dict | None, role: str | None,
|
||||||
|
spell_cap: float | None = None) -> dict:
|
||||||
stats = unit["stats"]
|
stats = unit["stats"]
|
||||||
hp = (stats.get("hp") or 0) * HP_STAR_MULT ** (stars - 1)
|
hp = (stats.get("hp") or 0) * HP_STAR_MULT ** (stars - 1)
|
||||||
ad = (stats.get("damage") or 0) * AD_STAR_MULT ** (stars - 1)
|
ad = (stats.get("damage") or 0) * AD_STAR_MULT ** (stars - 1)
|
||||||
@@ -138,6 +149,7 @@ def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict,
|
|||||||
|
|
||||||
as_eff = aspd * (1 + acc["as_pct"])
|
as_eff = aspd * (1 + acc["as_pct"])
|
||||||
crit_c = min(crit + acc["crit_chance"], 1.0)
|
crit_c = min(crit + acc["crit_chance"], 1.0)
|
||||||
|
crit_mult += acc["crit_dmg"]
|
||||||
auto_dps = ad * (1 + acc["ad_pct"]) * as_eff * (1 + crit_c * (crit_mult - 1))
|
auto_dps = ad * (1 + acc["ad_pct"]) * as_eff * (1 + crit_c * (crit_mult - 1))
|
||||||
|
|
||||||
spell_dps = 0.0
|
spell_dps = 0.0
|
||||||
@@ -149,11 +161,26 @@ def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict,
|
|||||||
dmg *= 1 + acc["ap_flat"] / 100
|
dmg *= 1 + acc["ap_flat"] / 100
|
||||||
if scaling in ("ad", "both"):
|
if scaling in ("ad", "both"):
|
||||||
dmg *= 1 + acc["ad_pct"]
|
dmg *= 1 + acc["ad_pct"]
|
||||||
frontline = FRONTLINE_MANA_PER_SEC if role == "frontline" else 0
|
if unit.get("spell_on_attack"):
|
||||||
cast_rate = min(
|
# Passive: Bonus-Schaden pro Auto-Attacke, kein Mana-Zyklus.
|
||||||
(as_eff * MANA_PER_ATTACK + frontline + acc["mana_regen"]) / mana_gap,
|
spell_dps = dmg * as_eff
|
||||||
CAST_RATE_CAP,
|
else:
|
||||||
)
|
frontline = FRONTLINE_MANA_PER_SEC if role == "frontline" else 0
|
||||||
spell_dps = dmg * cast_rate
|
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.
|
||||||
|
limit = SPELL_AUTO_CAP * auto_dps
|
||||||
|
if spell_cap is not None:
|
||||||
|
limit = max(limit, spell_cap * AD_STAR_MULT ** (stars - 1))
|
||||||
|
spell_dps = min(spell_dps, limit)
|
||||||
|
|
||||||
return {"ehp": ehp, "dps": (auto_dps + spell_dps) * (1 + acc["damage_amp"])}
|
return {
|
||||||
|
"ehp": ehp,
|
||||||
|
"dps": (auto_dps + spell_dps) * (1 + acc["damage_amp"]),
|
||||||
|
"spell_dps": spell_dps,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Scripted policies playing full games headless — sanity check for the sim."""
|
"""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
|
from tft.sim.game import Game
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ def play_econ(game: Game) -> int:
|
|||||||
while not game.over:
|
while not game.over:
|
||||||
policy.act(game.player, game.pool, game.artifact, game.cfg, game.rng,
|
policy.act(game.player, game.pool, game.artifact, game.cfg, game.rng,
|
||||||
game.round, params)
|
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()
|
game.step()
|
||||||
return game.placement
|
return game.placement
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,107 @@
|
|||||||
"""Combat = score comparison. Relative score difference -> win probability -> damage."""
|
"""Deterministischer Kampf: Gesamt-Offense arbeitet die Gegner-Units nacheinander ab.
|
||||||
|
|
||||||
|
Zielreihenfolge pro Seite: erst defensive Units (seeded zufällig gemischt),
|
||||||
|
dann offensive. Tote Units tragen keine Offense mehr — die Gesamt-Offense
|
||||||
|
einer Seite ist die Suffix-Summe ab dem aktuellen Ziel.
|
||||||
|
|
||||||
|
Ereignisgesteuert statt Frame-Schleife: zwischen zwei Todesereignissen sind
|
||||||
|
beide Offensen konstant, die Zeit bis zum nächsten Kill ist exakt def/off.
|
||||||
|
Das ist der Grenzwert der 60-FPS-Rechnung (Überschuss-Schaden trägt verlustfrei
|
||||||
|
über); die Frames dienen nur als Raster für Dauer und Zeitlimit.
|
||||||
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
FPS = 60
|
||||||
|
EPS = 1e-9
|
||||||
|
|
||||||
|
|
||||||
def win_probability(score_a: float, score_b: float, cfg: dict) -> float:
|
@dataclass
|
||||||
mean = (score_a + score_b) / 2
|
class FightResult:
|
||||||
if mean <= 0:
|
winner: str | None # "a" | "b" | None = Unentschieden
|
||||||
return 0.5
|
frames: int
|
||||||
d = (score_a - score_b) / mean
|
survivors_a: list[dict]
|
||||||
return 1 / (1 + math.exp(-d / cfg["combat"]["tau"]))
|
survivors_b: list[dict]
|
||||||
|
|
||||||
|
|
||||||
def resolve(score_a: float, score_b: float, cfg: dict, rng: random.Random) -> bool:
|
def _queue(team: list[dict], rng: random.Random) -> list[dict]:
|
||||||
"""True if A wins. Adds noise on top of the probability."""
|
"""Sterbereihenfolge: defensive Units zuerst, innerhalb der Gruppe zufällig."""
|
||||||
noise = rng.gauss(0, cfg["combat"]["variance"])
|
defensive = [p for p in team if p["defensive"]]
|
||||||
return rng.random() < min(max(win_probability(score_a, score_b, cfg) + noise, 0.02), 0.98)
|
offensive = [p for p in team if not p["defensive"]]
|
||||||
|
rng.shuffle(defensive)
|
||||||
|
rng.shuffle(offensive)
|
||||||
|
return defensive + offensive
|
||||||
|
|
||||||
|
|
||||||
def damage(stage: int, winner_score: float, loser_score: float, winner_units: int, cfg: dict) -> int:
|
def _suffix_off(queue: list[dict]) -> list[float]:
|
||||||
|
suffix = [0.0] * (len(queue) + 1)
|
||||||
|
for i in range(len(queue) - 1, -1, -1):
|
||||||
|
suffix[i] = suffix[i + 1] + queue[i]["off"]
|
||||||
|
return suffix
|
||||||
|
|
||||||
|
|
||||||
|
def fight(team_a: list[dict], team_b: list[dict], rng: random.Random,
|
||||||
|
cfg: dict) -> FightResult:
|
||||||
|
"""team_a/team_b: Profile aus score.board_profiles(). Beide Seiten ticken
|
||||||
|
gleichzeitig; same seed -> same outcome."""
|
||||||
|
qa, qb = _queue(team_a, rng), _queue(team_b, rng)
|
||||||
|
off_a, off_b = _suffix_off(qa), _suffix_off(qb)
|
||||||
|
limit = cfg["combat"]["max_frames"] / FPS
|
||||||
|
|
||||||
|
pa = pb = 0 # Zeiger auf die aktuell beschossene eigene Unit
|
||||||
|
ra = qa[0]["def"] if qa else 0.0 # deren Rest-Defense
|
||||||
|
rb = qb[0]["def"] if qb else 0.0
|
||||||
|
elapsed = 0.0
|
||||||
|
|
||||||
|
while pa < len(qa) and pb < len(qb):
|
||||||
|
oa, ob = off_a[pa], off_b[pb]
|
||||||
|
ta = rb / oa if oa > EPS else math.inf # Zeit bis A das B-Ziel killt
|
||||||
|
tb = ra / ob if ob > EPS else math.inf
|
||||||
|
dt = min(ta, tb)
|
||||||
|
if math.isinf(dt) or elapsed + dt >= limit:
|
||||||
|
dt = limit - elapsed
|
||||||
|
ra -= ob * dt
|
||||||
|
rb -= oa * dt
|
||||||
|
elapsed = limit
|
||||||
|
break
|
||||||
|
elapsed += dt
|
||||||
|
a_kills = ta <= tb + EPS
|
||||||
|
b_kills = tb <= ta + EPS
|
||||||
|
if a_kills:
|
||||||
|
pb += 1
|
||||||
|
rb = qb[pb]["def"] if pb < len(qb) else 0.0
|
||||||
|
else:
|
||||||
|
rb -= oa * dt
|
||||||
|
if b_kills:
|
||||||
|
pa += 1
|
||||||
|
ra = qa[pa]["def"] if pa < len(qa) else 0.0
|
||||||
|
else:
|
||||||
|
ra -= ob * dt
|
||||||
|
|
||||||
|
a_alive, b_alive = pa < len(qa), pb < len(qb)
|
||||||
|
if a_alive and b_alive:
|
||||||
|
# Zeitlimit erreicht: mehr verbleibende Gesamt-Defense gewinnt.
|
||||||
|
rest_a = ra + sum(p["def"] for p in qa[pa + 1:])
|
||||||
|
rest_b = rb + sum(p["def"] for p in qb[pb + 1:])
|
||||||
|
winner = "a" if rest_a > rest_b + EPS else "b" if rest_b > rest_a + EPS else None
|
||||||
|
elif a_alive:
|
||||||
|
winner = "a"
|
||||||
|
elif b_alive:
|
||||||
|
winner = "b"
|
||||||
|
else:
|
||||||
|
winner = None
|
||||||
|
|
||||||
|
return FightResult(
|
||||||
|
winner=winner,
|
||||||
|
frames=math.ceil(elapsed * FPS),
|
||||||
|
survivors_a=[p["unit"] for p in qa[pa:]],
|
||||||
|
survivors_b=[p["unit"] for p in qb[pb:]],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def player_damage(stage: int, survivors: list[dict], cfg: dict) -> int:
|
||||||
d = cfg["damage"]
|
d = cfg["damage"]
|
||||||
base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)]
|
base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)]
|
||||||
mean = (winner_score + loser_score) / 2 or 1
|
return base + d["per_surviving_unit"] * len(survivors)
|
||||||
margin = abs(winner_score - loser_score) / mean
|
|
||||||
surviving = max(1, round(winner_units * min(margin, 1.0)))
|
|
||||||
return base + d["per_surviving_unit"] * surviving
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
from tft.model.score import board_profiles
|
||||||
from tft.sim import combat, economy, player, policy
|
from tft.sim import combat, economy, player, policy
|
||||||
from tft.sim.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401
|
from tft.sim.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401
|
||||||
from tft.sim.pool import Pool
|
from tft.sim.pool import Pool
|
||||||
@@ -53,6 +54,7 @@ class Game:
|
|||||||
for p in self.players:
|
for p in self.players:
|
||||||
p.shop = [None] * cfg["shop"]["slots"]
|
p.shop = [None] * cfg["shop"]["slots"]
|
||||||
self._maybe_offer_augment()
|
self._maybe_offer_augment()
|
||||||
|
self._bots_act()
|
||||||
|
|
||||||
# ---- helpers ----
|
# ---- helpers ----
|
||||||
|
|
||||||
@@ -80,20 +82,40 @@ class Game:
|
|||||||
if p is self.player:
|
if p is self.player:
|
||||||
self.log.append(f"{self.round['label']}: Carousel")
|
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:
|
def _maybe_offer_augment(self) -> None:
|
||||||
if not self.round.get("augment"):
|
if not self.round.get("augment"):
|
||||||
return
|
return
|
||||||
available = [a for a in self.artifact["static"]["augments"]
|
augments = self.artifact["static"]["augments"]
|
||||||
if a not in self.player.augments]
|
specs = self.artifact.get("augment_specs", {})
|
||||||
|
pool = [a for a in (self.artifact.get("augment_pool") or list(augments))
|
||||||
|
if a in augments and specs.get(a, {}).get("offerable", True)]
|
||||||
|
# Pro Runde eine Stufe rollen; alle Angebote (Spieler + Bots) kommen daraus.
|
||||||
|
weights = self.cfg.get("augments", {}).get("tier_weights", {}).get(
|
||||||
|
self.round["label"], [35, 50, 15]
|
||||||
|
)
|
||||||
|
tier = self.rng.choices((1, 2, 3), weights=weights)[0]
|
||||||
|
tier_pool = [a for a in pool if (augments[a].get("tier") or 2) == tier] or pool
|
||||||
|
|
||||||
|
available = [a for a in tier_pool if a not in self.player.augments]
|
||||||
if available and not self.player.augment_offer:
|
if available and not self.player.augment_offer:
|
||||||
self.player.augment_offer = self.rng.sample(available, min(3, len(available)))
|
self.player.augment_offer = self.rng.sample(available, min(3, len(available)))
|
||||||
for b in self.bots:
|
for b in self.bots:
|
||||||
if not b.alive:
|
if not b.alive:
|
||||||
continue
|
continue
|
||||||
choices = [a for a in self.artifact["static"]["augments"]
|
choices = [a for a in tier_pool if a not in b.augments]
|
||||||
if a not in b.augments]
|
|
||||||
if choices:
|
if choices:
|
||||||
b.augments.append(self.rng.choice(choices))
|
chosen = self.rng.choice(choices)
|
||||||
|
b.augments.append(chosen)
|
||||||
|
self._apply_augment(b, chosen)
|
||||||
|
|
||||||
# ---- human actions (delegation) ----
|
# ---- human actions (delegation) ----
|
||||||
|
|
||||||
@@ -113,13 +135,57 @@ class Game:
|
|||||||
player.move(self.player, where, idx)
|
player.move(self.player, where, idx)
|
||||||
|
|
||||||
def equip(self, item_idx: int, board_idx: int) -> None:
|
def equip(self, item_idx: int, board_idx: int) -> None:
|
||||||
player.equip(self.player, item_idx, board_idx)
|
player.equip(self.player, item_idx, board_idx, self.artifact)
|
||||||
|
|
||||||
def toggle_lock(self) -> None:
|
def toggle_lock(self) -> None:
|
||||||
self.player.shop_locked = not self.player.shop_locked
|
self.player.shop_locked = not self.player.shop_locked
|
||||||
|
|
||||||
def pick_augment(self, choice: int) -> None:
|
def pick_augment(self, choice: int) -> None:
|
||||||
|
api = self.player.augment_offer[choice] \
|
||||||
|
if 0 <= choice < len(self.player.augment_offer) else None
|
||||||
player.pick_augment(self.player, choice)
|
player.pick_augment(self.player, choice)
|
||||||
|
if api:
|
||||||
|
self._apply_augment(self.player, api)
|
||||||
|
|
||||||
|
def _apply_augment(self, p: PlayerState, api: str) -> None:
|
||||||
|
"""Deterministische Augment-Atome sofort bzw. als Dauereffekt anwenden."""
|
||||||
|
spec = self.artifact.get("augment_specs", {}).get(api, {})
|
||||||
|
for atom in spec.get("atoms", []):
|
||||||
|
kind = atom["kind"]
|
||||||
|
if kind == "gold_now":
|
||||||
|
p.gold += atom["amount"]
|
||||||
|
elif kind == "gold_per_stage":
|
||||||
|
p.gold_per_stage += atom["amount"]
|
||||||
|
elif kind == "xp_now":
|
||||||
|
p.level, p.xp = economy.apply_xp(p.level, p.xp, atom["amount"], self.cfg)
|
||||||
|
elif kind == "xp_per_round":
|
||||||
|
p.xp_per_round += atom["amount"]
|
||||||
|
elif kind == "player_hp":
|
||||||
|
p.hp += atom["amount"]
|
||||||
|
elif kind == "components":
|
||||||
|
player.grant_loot(p, atom["count"], 0, self.artifact, self.rng)
|
||||||
|
elif kind == "completed_items":
|
||||||
|
pool = self.artifact.get("item_pool") or []
|
||||||
|
for _ in range(atom["count"]):
|
||||||
|
if pool:
|
||||||
|
p.items.append(self.rng.choice(pool))
|
||||||
|
elif kind == "unit_grant":
|
||||||
|
self._grant_units(p, atom)
|
||||||
|
# team_buff wirkt im Score, nicht hier.
|
||||||
|
|
||||||
|
def _grant_units(self, p: PlayerState, atom: dict) -> None:
|
||||||
|
apis = list(atom.get("units", []))
|
||||||
|
if "cost" in atom:
|
||||||
|
for _ in range(atom.get("count", 1)):
|
||||||
|
candidates = self.pool.units_of_cost(atom["cost"])
|
||||||
|
if candidates:
|
||||||
|
apis.append(self.rng.choice(candidates))
|
||||||
|
for api in apis:
|
||||||
|
if len(p.bench) >= player.BENCH_SIZE or self.pool.available(api) < 1:
|
||||||
|
continue
|
||||||
|
self.pool.take(api)
|
||||||
|
p.bench.append({"api_name": api, "stars": 1, "items": []})
|
||||||
|
player.merge(p, api, 1)
|
||||||
|
|
||||||
# ---- round resolution ----
|
# ---- round resolution ----
|
||||||
|
|
||||||
@@ -131,12 +197,10 @@ class Game:
|
|||||||
self.pick_augment(self.rng.randrange(len(self.player.augment_offer)))
|
self.pick_augment(self.rng.randrange(len(self.player.augment_offer)))
|
||||||
|
|
||||||
rnd = self.round
|
rnd = self.round
|
||||||
for b in self.bots:
|
# Bots haben schon beim Rundenstart gezogen (_bots_act).
|
||||||
if b.alive:
|
# swap=False: die manuelle Aufstellung des Spielers bleibt unangetastet.
|
||||||
policy.act(b, self.pool, self.artifact, self.cfg, self.rng, rnd,
|
if self.player.alive:
|
||||||
policy.ARCHETYPES[b.archetype])
|
player.fill_board(self.player, self.artifact, swap=False)
|
||||||
for p in self._alive():
|
|
||||||
player.fill_board(p, self.artifact)
|
|
||||||
|
|
||||||
results = self._resolve(rnd)
|
results = self._resolve(rnd)
|
||||||
self._check_eliminations()
|
self._check_eliminations()
|
||||||
@@ -153,7 +217,8 @@ class Game:
|
|||||||
p.gold, p.streak if is_pvp else 0, is_pvp and won, rnd["label"], self.cfg
|
p.gold, p.streak if is_pvp else 0, is_pvp and won, rnd["label"], self.cfg
|
||||||
)
|
)
|
||||||
p.level, p.xp = economy.apply_xp(
|
p.level, p.xp = economy.apply_xp(
|
||||||
p.level, p.xp, self.cfg["xp"]["passive_per_round"], self.cfg
|
p.level, p.xp,
|
||||||
|
self.cfg["xp"]["passive_per_round"] + p.xp_per_round, self.cfg
|
||||||
)
|
)
|
||||||
|
|
||||||
self._advance()
|
self._advance()
|
||||||
@@ -171,17 +236,19 @@ class Game:
|
|||||||
pairs, odd, ghost_src = pair_players(self._alive(), self.rng)
|
pairs, odd, ghost_src = pair_players(self._alive(), self.rng)
|
||||||
for a, b in pairs:
|
for a, b in pairs:
|
||||||
a_wins = self._fight(rnd, a, b)
|
a_wins = self._fight(rnd, a, b)
|
||||||
results[a.name] = a_wins
|
# Unentschieden (None) zählt für beide als nicht gewonnen.
|
||||||
results[b.name] = not a_wins
|
results[a.name] = a_wins is True
|
||||||
|
results[b.name] = a_wins is False
|
||||||
if odd is not None:
|
if odd is not None:
|
||||||
# Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler.
|
# Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler.
|
||||||
s_odd = player.score(odd, self.artifact)
|
res = combat.fight(board_profiles(odd.board, self.artifact),
|
||||||
s_ghost = player.score(ghost_src, self.artifact)
|
board_profiles(ghost_src.board, self.artifact),
|
||||||
won = combat.resolve(s_odd, s_ghost, self.cfg, self.rng)
|
self.rng, self.cfg)
|
||||||
|
won = res.winner == "a"
|
||||||
results[odd.name] = won
|
results[odd.name] = won
|
||||||
if not won:
|
if not won:
|
||||||
dmg = combat.damage(rnd["stage"], s_ghost, s_odd,
|
survivors = res.survivors_b if res.winner == "b" else []
|
||||||
len(ghost_src.board), self.cfg)
|
dmg = combat.player_damage(rnd["stage"], survivors, self.cfg)
|
||||||
odd.hp -= dmg
|
odd.hp -= dmg
|
||||||
if odd is self.player:
|
if odd is self.player:
|
||||||
self.log.append(
|
self.log.append(
|
||||||
@@ -190,13 +257,23 @@ class Game:
|
|||||||
self.log.append(f"{rnd['label']}: Sieg vs Ghost ({ghost_src.name})")
|
self.log.append(f"{rnd['label']}: Sieg vs Ghost ({ghost_src.name})")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool:
|
def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool | None:
|
||||||
sa = player.score(a, self.artifact)
|
res = combat.fight(board_profiles(a.board, self.artifact),
|
||||||
sb = player.score(b, self.artifact)
|
board_profiles(b.board, self.artifact),
|
||||||
a_wins = combat.resolve(sa, sb, self.cfg, self.rng)
|
self.rng, self.cfg)
|
||||||
|
if res.winner is None:
|
||||||
|
# Unentschieden: beide nehmen den Basis-Schaden der Stage.
|
||||||
|
dmg = combat.player_damage(rnd["stage"], [], self.cfg)
|
||||||
|
a.hp -= dmg
|
||||||
|
b.hp -= dmg
|
||||||
|
if a is self.player or b is self.player:
|
||||||
|
other = b if a is self.player else a
|
||||||
|
self.log.append(f"{rnd['label']}: Unentschieden vs {other.name} (-{dmg} HP)")
|
||||||
|
return None
|
||||||
|
a_wins = res.winner == "a"
|
||||||
winner, loser = (a, b) if a_wins else (b, a)
|
winner, loser = (a, b) if a_wins else (b, a)
|
||||||
w_score, l_score = (sa, sb) if a_wins else (sb, sa)
|
survivors = res.survivors_a if a_wins else res.survivors_b
|
||||||
dmg = combat.damage(rnd["stage"], w_score, l_score, len(winner.board), self.cfg)
|
dmg = combat.player_damage(rnd["stage"], survivors, self.cfg)
|
||||||
loser.hp -= dmg
|
loser.hp -= dmg
|
||||||
if a is self.player or b is self.player:
|
if a is self.player or b is self.player:
|
||||||
if winner is self.player:
|
if winner is self.player:
|
||||||
@@ -220,6 +297,7 @@ class Game:
|
|||||||
|
|
||||||
def _advance(self) -> None:
|
def _advance(self) -> None:
|
||||||
"""Zur nächsten Planungsrunde; Carousels laufen dabei automatisch für alle ab."""
|
"""Zur nächsten Planungsrunde; Carousels laufen dabei automatisch für alle ab."""
|
||||||
|
prev_stage = self.round["stage"]
|
||||||
while True:
|
while True:
|
||||||
if self.idx + 1 >= len(self.rounds):
|
if self.idx + 1 >= len(self.rounds):
|
||||||
self._finish_by_hp()
|
self._finish_by_hp()
|
||||||
@@ -230,11 +308,15 @@ class Game:
|
|||||||
# Carousel gibt weder Einkommen noch XP — nur Loot und Unit.
|
# Carousel gibt weder Einkommen noch XP — nur Loot und Unit.
|
||||||
for p in self._alive():
|
for p in self._alive():
|
||||||
self._carousel(p)
|
self._carousel(p)
|
||||||
|
if self.round["stage"] != prev_stage:
|
||||||
|
for p in self._alive():
|
||||||
|
p.gold += p.gold_per_stage # Augment: Gold zum Stage-Start
|
||||||
for p in self._alive():
|
for p in self._alive():
|
||||||
if p is self.player and p.shop_locked:
|
if p is self.player and p.shop_locked:
|
||||||
continue
|
continue
|
||||||
player.refresh_shop(p, self.pool, self.cfg, self.rng)
|
player.refresh_shop(p, self.pool, self.cfg, self.rng)
|
||||||
self._maybe_offer_augment()
|
self._maybe_offer_augment()
|
||||||
|
self._bots_act()
|
||||||
|
|
||||||
def _finish_by_hp(self) -> None:
|
def _finish_by_hp(self) -> None:
|
||||||
standings = sorted(self._alive(), key=lambda p: -p.hp)
|
standings = sorted(self._alive(), key=lambda p: -p.hp)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class PlayerState:
|
|||||||
augment_offer: list = field(default_factory=list)
|
augment_offer: list = field(default_factory=list)
|
||||||
placement: int | None = None
|
placement: int | None = None
|
||||||
last_opponent: str | None = None
|
last_opponent: str | None = None
|
||||||
|
gold_per_stage: int = 0
|
||||||
|
xp_per_round: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alive(self) -> bool:
|
def alive(self) -> bool:
|
||||||
@@ -140,14 +142,28 @@ def move(p: PlayerState, where: str, idx: int) -> None:
|
|||||||
dst.append(src.pop(idx))
|
dst.append(src.pop(idx))
|
||||||
|
|
||||||
|
|
||||||
def equip(p: PlayerState, item_idx: int, board_idx: int) -> None:
|
def equip(p: PlayerState, item_idx: int, board_idx: int, artifact: dict | None = None) -> None:
|
||||||
if not (0 <= item_idx < len(p.items)):
|
if not (0 <= item_idx < len(p.items)):
|
||||||
raise InvalidAction("no such item")
|
raise InvalidAction("no such item")
|
||||||
if not (0 <= board_idx < len(p.board)):
|
if not (0 <= board_idx < len(p.board)):
|
||||||
raise InvalidAction("no such unit")
|
raise InvalidAction("no such unit")
|
||||||
if len(p.board[board_idx]["items"]) >= MAX_ITEMS_PER_UNIT:
|
unit = p.board[board_idx]
|
||||||
|
new_item = p.items[item_idx]
|
||||||
|
recipes = (artifact or {}).get("recipes", {})
|
||||||
|
components = set((artifact or {}).get("component_pool", []))
|
||||||
|
|
||||||
|
# Komponente auf Unit mit passender Komponente -> fertiges Item craften.
|
||||||
|
if new_item in components:
|
||||||
|
for slot, held in enumerate(unit["items"]):
|
||||||
|
crafted = recipes.get("|".join(sorted((held, new_item))))
|
||||||
|
if crafted:
|
||||||
|
unit["items"][slot] = crafted
|
||||||
|
p.items.pop(item_idx)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(unit["items"]) >= MAX_ITEMS_PER_UNIT:
|
||||||
raise InvalidAction("unit has 3 items")
|
raise InvalidAction("unit has 3 items")
|
||||||
p.board[board_idx]["items"].append(p.items.pop(item_idx))
|
unit["items"].append(p.items.pop(item_idx))
|
||||||
|
|
||||||
|
|
||||||
def pick_augment(p: PlayerState, choice: int) -> None:
|
def pick_augment(p: PlayerState, choice: int) -> None:
|
||||||
@@ -161,9 +177,11 @@ def pick_augment(p: PlayerState, choice: int) -> None:
|
|||||||
|
|
||||||
def grant_loot(p: PlayerState, components: int, gold: int, artifact: dict,
|
def grant_loot(p: PlayerState, components: int, gold: int, artifact: dict,
|
||||||
rng: random.Random) -> None:
|
rng: random.Random) -> None:
|
||||||
items = artifact.get("item_pool") or list(artifact["static"]["items"])
|
# Loot droppt Komponenten; fertige Items entstehen nur durch Craften.
|
||||||
|
pool = (artifact.get("component_pool") or artifact.get("item_pool")
|
||||||
|
or list(artifact["static"]["items"]))
|
||||||
for _ in range(components):
|
for _ in range(components):
|
||||||
p.items.append(rng.choice(items))
|
p.items.append(rng.choice(pool))
|
||||||
p.gold += gold
|
p.gold += gold
|
||||||
|
|
||||||
|
|
||||||
@@ -176,8 +194,20 @@ def grab_carousel_unit(p: PlayerState, pool: Pool, rng: random.Random) -> None:
|
|||||||
merge(p, api, 1)
|
merge(p, api, 1)
|
||||||
|
|
||||||
|
|
||||||
def fill_board(p: PlayerState, artifact: dict) -> None:
|
def fill_board(p: PlayerState, artifact: dict, swap: bool = True) -> None:
|
||||||
"""Wie in TFT: freie Board-Plätze werden beim Kampfstart von der Bank aufgefüllt."""
|
"""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))
|
p.bench.sort(key=lambda u: -unit_worth(artifact, u))
|
||||||
while len(p.board) < p.level and p.bench:
|
while len(p.board) < p.level and p.bench:
|
||||||
p.board.append(p.bench.pop(0))
|
p.board.append(p.bench.pop(0))
|
||||||
|
if not swap:
|
||||||
|
return
|
||||||
|
for i, u in enumerate(p.board):
|
||||||
|
if not p.bench:
|
||||||
|
break
|
||||||
|
best = max(p.bench, key=lambda b: unit_worth(artifact, b))
|
||||||
|
if unit_worth(artifact, best) > unit_worth(artifact, u):
|
||||||
|
p.bench.remove(best)
|
||||||
|
p.bench.append(u)
|
||||||
|
p.board[i] = best
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
"""Scripted planning policies — drive the bots and the autoplay benchmarks."""
|
"""Scripted planning policies — drive the bots and the autoplay benchmarks.
|
||||||
|
|
||||||
|
Level-Ziele und Rolldown-Zeitpunkte nach Standard-Spielweisen (Set 17):
|
||||||
|
Baseline L4@2-1, L5@2-5, L6@3-2, L7@4-1, L8@4-5. Fast 8 spiked bei 4-2,
|
||||||
|
Streaker levelt früh und rollt klein, Reroll cappt bei 6 und slow-rollt >50.
|
||||||
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
@@ -7,45 +12,83 @@ from tft.sim.player import InvalidAction, PlayerState
|
|||||||
from tft.sim.pool import Pool
|
from tft.sim.pool import Pool
|
||||||
|
|
||||||
ARCHETYPES = {
|
ARCHETYPES = {
|
||||||
# fast8: früh econ, ab Stage 3 leveln, Rolldown ab Level 8.
|
# roll_at_level: ab diesem Level (und Stage >= 4) wird auf roll_floor gerollt.
|
||||||
"fast8": {"level_cap": 10, "xp_stage": 3, "econ_floor": 54,
|
"fast8": {
|
||||||
"roll_stage": 4, "roll_level": 8, "roll_floor": 30},
|
"level_targets": {"2-1": 4, "2-5": 5, "3-2": 6, "4-1": 7, "4-2": 8, "5-2": 9},
|
||||||
# reroll: Level-Cap 7, rollt Überschuss über 50 für Upgrades.
|
"roll_at_level": 8,
|
||||||
"reroll": {"level_cap": 7, "xp_stage": 3, "econ_floor": 50,
|
"roll_floor": 30,
|
||||||
"roll_stage": 3, "roll_level": 7, "roll_floor": 50},
|
"roll_low_hp": 40,
|
||||||
# streaker: gibt früh aus, levelt aggressiv, wenig Reserve.
|
"slow_roll_level": None,
|
||||||
"streaker": {"level_cap": 10, "xp_stage": 2, "econ_floor": 20,
|
},
|
||||||
"roll_stage": 4, "roll_level": 8, "roll_floor": 20},
|
"streaker": {
|
||||||
|
"level_targets": {"2-1": 4, "2-5": 5, "3-1": 6, "3-5": 7, "4-5": 8, "5-5": 9},
|
||||||
|
"roll_at_level": 7,
|
||||||
|
"roll_floor": 30,
|
||||||
|
"roll_low_hp": 50,
|
||||||
|
"slow_roll_level": None,
|
||||||
|
},
|
||||||
|
"reroll": {
|
||||||
|
"level_targets": {"2-1": 4, "2-5": 5, "3-2": 6, "5-1": 7, "6-1": 8},
|
||||||
|
"roll_at_level": None,
|
||||||
|
"roll_floor": 30,
|
||||||
|
"roll_low_hp": 40,
|
||||||
|
"slow_roll_level": 6, # ab Level 6: Gold über 50 verrollen (Slow-Roll)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _round_key(label: str) -> tuple[int, int]:
|
||||||
|
stage, rnd = label.split("-")
|
||||||
|
return int(stage), int(rnd)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_level(params: dict, label: str) -> int:
|
||||||
|
key = _round_key(label)
|
||||||
|
return max(
|
||||||
|
(lvl for rnd, lvl in params["level_targets"].items() if _round_key(rnd) <= key),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def act(p: PlayerState, pool: Pool, artifact: dict, cfg: dict, rng: random.Random,
|
def act(p: PlayerState, pool: Pool, artifact: dict, cfg: dict, rng: random.Random,
|
||||||
rnd: dict, params: dict) -> None:
|
rnd: dict, params: dict) -> None:
|
||||||
"""Eine Planungsrunde für einen Spieler."""
|
"""Eine Planungsrunde für einen Spieler."""
|
||||||
if p.augment_offer:
|
if p.augment_offer:
|
||||||
player.pick_augment(p, rng.randrange(len(p.augment_offer)))
|
player.pick_augment(p, rng.randrange(len(p.augment_offer)))
|
||||||
|
|
||||||
_buy_from_shop(p, pool, artifact)
|
# Geplantes Leveln hat Vorrang vor Shop-Käufen ("on curve").
|
||||||
|
label = rnd["label"]
|
||||||
stage = rnd["stage"]
|
target = _target_level(params, label)
|
||||||
while (stage >= params["xp_stage"] and p.level < params["level_cap"]
|
while p.level < target and p.gold >= cfg["xp"]["buy_cost"]:
|
||||||
and p.gold >= params["econ_floor"] + cfg["xp"]["buy_cost"]):
|
|
||||||
try:
|
try:
|
||||||
player.buy_xp(p, cfg)
|
player.buy_xp(p, cfg)
|
||||||
except InvalidAction:
|
except InvalidAction:
|
||||||
break
|
break
|
||||||
|
|
||||||
at_target = p.level >= params["roll_level"] or p.level >= params["level_cap"]
|
_buy_from_shop(p, pool, artifact)
|
||||||
if stage >= params["roll_stage"] and at_target:
|
|
||||||
|
floor = None
|
||||||
|
slow_level = params.get("slow_roll_level")
|
||||||
|
if slow_level is not None and p.level >= slow_level:
|
||||||
|
floor = 50
|
||||||
|
if p.level >= max(params["level_targets"].values()):
|
||||||
|
floor = 50 # Endstufe erreicht: Überschuss in Upgrades rollen
|
||||||
|
roll_level = params.get("roll_at_level")
|
||||||
|
if roll_level is not None and p.level >= roll_level and rnd["stage"] >= 4:
|
||||||
|
floor = params["roll_floor"]
|
||||||
|
if rnd["stage"] >= 4 and p.hp < params["roll_low_hp"]:
|
||||||
|
floor = 0 # Not-Rolldown bei niedrigem Leben
|
||||||
|
|
||||||
|
if floor is not None:
|
||||||
_sell_junk(p, pool, artifact)
|
_sell_junk(p, pool, artifact)
|
||||||
while p.gold > params["roll_floor"]:
|
while p.gold > floor:
|
||||||
_buy_upgrades(p, pool, artifact)
|
_buy_upgrades(p, pool, artifact)
|
||||||
try:
|
try:
|
||||||
player.reroll(p, pool, cfg, rng)
|
player.reroll(p, pool, cfg, rng)
|
||||||
except InvalidAction:
|
except InvalidAction:
|
||||||
break
|
break
|
||||||
|
|
||||||
_equip_items(p)
|
_equip_items(p, artifact)
|
||||||
player.fill_board(p, artifact)
|
player.fill_board(p, artifact)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,13 +107,15 @@ def _buy_from_shop(p: PlayerState, pool: Pool, artifact: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _buy_upgrades(p: PlayerState, pool: Pool, artifact: dict) -> None:
|
def _buy_upgrades(p: PlayerState, pool: Pool, artifact: dict) -> None:
|
||||||
|
"""Im Rolldown: Kopien für Upgrades UND neue starke Carries (4-Cost+) kaufen."""
|
||||||
for slot, api in enumerate(list(p.shop)):
|
for slot, api in enumerate(list(p.shop)):
|
||||||
if api is None:
|
if api is None:
|
||||||
continue
|
continue
|
||||||
cost = artifact["static"]["units"][api]["cost"]
|
cost = artifact["static"]["units"][api]["cost"]
|
||||||
owned = sum(1 for u in p.board + p.bench
|
owned = sum(1 for u in p.board + p.bench
|
||||||
if u["api_name"] == api and u["stars"] < 3)
|
if u["api_name"] == api and u["stars"] < 3)
|
||||||
if p.gold >= cost and owned >= 1:
|
wants = owned >= 1 or (cost >= 4 and len(p.bench) < 7)
|
||||||
|
if p.gold >= cost and wants:
|
||||||
try:
|
try:
|
||||||
player.buy(p, slot, pool, artifact)
|
player.buy(p, slot, pool, artifact)
|
||||||
except InvalidAction:
|
except InvalidAction:
|
||||||
@@ -87,10 +132,10 @@ def _sell_junk(p: PlayerState, pool: Pool, artifact: dict) -> None:
|
|||||||
player.sell(p, "bench", idx, pool, artifact)
|
player.sell(p, "bench", idx, pool, artifact)
|
||||||
|
|
||||||
|
|
||||||
def _equip_items(p: PlayerState) -> None:
|
def _equip_items(p: PlayerState, artifact: dict) -> None:
|
||||||
while p.items and any(len(u["items"]) < 3 for u in p.board):
|
while p.items and any(len(u["items"]) < 3 for u in p.board):
|
||||||
target = min(range(len(p.board)), key=lambda i: len(p.board[i]["items"]))
|
target = min(range(len(p.board)), key=lambda i: len(p.board[i]["items"]))
|
||||||
try:
|
try:
|
||||||
player.equip(p, 0, target)
|
player.equip(p, 0, target, artifact)
|
||||||
except InvalidAction:
|
except InvalidAction:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -18,6 +18,38 @@ DAMAGE_FALLBACKS = (
|
|||||||
)
|
)
|
||||||
TAG_TO_TYPE = {"magicDamage": "magic", "physicalDamage": "physical", "trueDamage": "true"}
|
TAG_TO_TYPE = {"magicDamage": "magic", "physicalDamage": "physical", "trueDamage": "true"}
|
||||||
|
|
||||||
|
# "Attacks deal <tag>@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.]+))?@")
|
||||||
|
|
||||||
|
|
||||||
|
def augment_tier(icon_path: str | None) -> int | None:
|
||||||
|
m = AUGMENT_TIER_RE.search(icon_path or "")
|
||||||
|
return len(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def render_desc(desc: str | None, effects: dict) -> str:
|
||||||
|
"""Tooltip-Platzhalter mit Effekt-Werten füllen, Markup entfernen."""
|
||||||
|
lowered = {k.lower(): v for k, v in (effects or {}).items()
|
||||||
|
if isinstance(v, (int, float))}
|
||||||
|
|
||||||
|
def repl(m: re.Match) -> str:
|
||||||
|
value = lowered.get(m.group(1).lower())
|
||||||
|
if value is None:
|
||||||
|
return "?"
|
||||||
|
if m.group(2):
|
||||||
|
value *= float(m.group(2))
|
||||||
|
return str(int(value)) if float(value).is_integer() else str(round(value, 1))
|
||||||
|
|
||||||
|
text = PLACEHOLDER_RE.sub(repl, desc or "")
|
||||||
|
text = re.sub(r"<[^>]+>", " ", text)
|
||||||
|
text = re.sub(r"%i:[^%]+%", "", text)
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
def resolve_spell(ability: dict) -> dict:
|
def resolve_spell(ability: dict) -> dict:
|
||||||
"""Spell-Schaden pro Stern aus Desc-Markup + Variablen auflösen.
|
"""Spell-Schaden pro Stern aus Desc-Markup + Variablen auflösen.
|
||||||
@@ -34,12 +66,18 @@ def resolve_spell(ability: dict) -> dict:
|
|||||||
|
|
||||||
dmg_type = None
|
dmg_type = None
|
||||||
array = None
|
array = None
|
||||||
|
on_attack = False
|
||||||
for m in DAMAGE_TAG_RE.finditer(desc):
|
for m in DAMAGE_TAG_RE.finditer(desc):
|
||||||
for vm in VAR_RE.finditer(m.group(2)):
|
for vm in VAR_RE.finditer(m.group(2)):
|
||||||
name = vm.group(1)
|
name = vm.group(1)
|
||||||
array = get(name) or get(name.removeprefix("Modified"))
|
array = get(name) or get(name.removeprefix("Modified"))
|
||||||
if array:
|
if array:
|
||||||
dmg_type = TAG_TO_TYPE[m.group(1)]
|
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
|
break
|
||||||
if array:
|
if array:
|
||||||
break
|
break
|
||||||
@@ -52,6 +90,7 @@ def resolve_spell(ability: dict) -> dict:
|
|||||||
for name in DAMAGE_FALLBACKS:
|
for name in DAMAGE_FALLBACKS:
|
||||||
array = get(name)
|
array = get(name)
|
||||||
if array:
|
if array:
|
||||||
|
on_attack = bool(ATTACK_VAR_RE.search(name))
|
||||||
break
|
break
|
||||||
|
|
||||||
has_ap = "%i:scaleAP%" in desc
|
has_ap = "%i:scaleAP%" in desc
|
||||||
@@ -62,6 +101,7 @@ def resolve_spell(ability: dict) -> dict:
|
|||||||
"spell_damage": array,
|
"spell_damage": array,
|
||||||
"spell_damage_type": dmg_type or ("magic" if array else None),
|
"spell_damage_type": dmg_type or ("magic" if array else None),
|
||||||
"spell_scaling": scaling,
|
"spell_scaling": scaling,
|
||||||
|
"spell_on_attack": on_attack,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +166,11 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
|||||||
augments = {}
|
augments = {}
|
||||||
for i in raw["items"]:
|
for i in raw["items"]:
|
||||||
api = i["apiName"]
|
api = i["apiName"]
|
||||||
if not (api.startswith(set_prefix) or api.startswith("TFT_Item_")):
|
is_augment = "Augment" in api
|
||||||
|
# Augments: Set-eigene + generischer Evergreen-Pool (TFT_Augment_*).
|
||||||
|
prefixes = (set_prefix, "TFT_Item_", "TFT_Augment_") if is_augment \
|
||||||
|
else (set_prefix, "TFT_Item_")
|
||||||
|
if not api.startswith(prefixes):
|
||||||
continue
|
continue
|
||||||
entry = {
|
entry = {
|
||||||
"api_name": api,
|
"api_name": api,
|
||||||
@@ -135,7 +179,12 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
|||||||
"effects": i.get("effects") or {},
|
"effects": i.get("effects") or {},
|
||||||
"icon": icon_url(i["icon"]) if i["icon"] else None,
|
"icon": icon_url(i["icon"]) if i["icon"] else None,
|
||||||
}
|
}
|
||||||
if "Augment" in api:
|
if is_augment:
|
||||||
|
# Müll raus: Shop-Mechanik-Angebote und unaufgelöste Tooltip-Namen.
|
||||||
|
if "MarketOffering" in api or "@" in (i["name"] or "") or "{" in (i["name"] or ""):
|
||||||
|
continue
|
||||||
|
entry["tier"] = augment_tier(i.get("icon"))
|
||||||
|
entry["desc"] = render_desc(i.get("desc"), i.get("effects") or {})
|
||||||
augments[api] = entry
|
augments[api] = entry
|
||||||
else:
|
else:
|
||||||
items[api] = entry
|
items[api] = entry
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ const standings = computed(() => {
|
|||||||
},
|
},
|
||||||
...s.value.opponents,
|
...s.value.opponents,
|
||||||
]
|
]
|
||||||
return rows.sort((a, b) => b.hp - a.hp || (a.placement ?? 9) - (b.placement ?? 9))
|
return rows.sort((a, b) => {
|
||||||
|
if (a.alive !== b.alive) return a.alive ? -1 : 1
|
||||||
|
if (!a.alive) return (a.placement ?? 9) - (b.placement ?? 9)
|
||||||
|
return (b.score ?? 0) - (a.score ?? 0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -59,9 +63,16 @@ const standings = computed(() => {
|
|||||||
<div v-if="s.augment_offer.length" class="augments">
|
<div v-if="s.augment_offer.length" class="augments">
|
||||||
<h3>Augment wählen</h3>
|
<h3>Augment wählen</h3>
|
||||||
<div class="offer">
|
<div class="offer">
|
||||||
<button v-for="(a, i) in s.augment_offer" :key="a.api_name" @click="store.pickAugment(i)">
|
<button
|
||||||
|
v-for="(a, i) in s.augment_offer"
|
||||||
|
:key="a.api_name"
|
||||||
|
class="augcard"
|
||||||
|
:class="'tier' + (a.tier || 2)"
|
||||||
|
@click="store.pickAugment(i)"
|
||||||
|
>
|
||||||
<img v-if="a.icon" :src="a.icon" :alt="a.name" />
|
<img v-if="a.icon" :src="a.icon" :alt="a.name" />
|
||||||
{{ a.name }}
|
<span class="augname">{{ a.name }}</span>
|
||||||
|
<span class="augdesc">{{ a.desc }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,9 +182,20 @@ header {
|
|||||||
}
|
}
|
||||||
.augments { background: #1d2438; border-radius: 8px; padding: 10px 14px; }
|
.augments { background: #1d2438; border-radius: 8px; padding: 10px 14px; }
|
||||||
.augments h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 6px; }
|
.augments h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 6px; }
|
||||||
.offer { display: flex; gap: 8px; }
|
.offer { display: flex; gap: 10px; }
|
||||||
.offer button { display: flex; align-items: center; gap: 8px; flex: 1; }
|
.augcard {
|
||||||
.offer img { width: 28px; height: 28px; }
|
flex: 1;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
text-align: center;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
.augcard img { width: 44px; height: 44px; }
|
||||||
|
.augname { font-weight: 700; font-size: 14px; }
|
||||||
|
.augdesc { font-size: 12px; color: #aab2c8; line-height: 1.35; }
|
||||||
|
.augcard.tier1 { border-color: #b9bec9; }
|
||||||
|
.augcard.tier2 { border-color: #e8c35a; }
|
||||||
|
.augcard.tier3 { border-color: #b153d8; box-shadow: 0 0 10px #b153d855; }
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1; min-height: 0;
|
flex: 1; min-height: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user