From e12c2595fc5da40ab1677ca6be07215a53a77903 Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 7 Sep 2026 07:44:09 +0200 Subject: [PATCH] update --- README.md | 7 +- backend/pyproject.toml | 2 + backend/tests/test_api.py | 84 ++++- backend/tests/test_arena.py | 103 ++++++ backend/tests/test_battle.py | 206 +++++++++++ backend/tests/test_carries.py | 117 ++++++ backend/tests/test_combat.py | 92 ----- backend/tests/test_fullgame.py | 257 +++++++++++++ backend/tests/test_game.py | 98 ++++- backend/tests/test_groups.py | 197 ++++++++++ backend/tests/test_hexgrid.py | 46 +++ backend/tests/test_learn.py | 120 +++++- backend/tests/test_metatft.py | 203 ++++++++++ backend/tests/test_pairs.py | 113 ++++++ backend/tests/test_parse.py | 20 +- backend/tests/test_rating.py | 79 ++++ backend/tests/test_score.py | 146 -------- backend/tests/test_statsheet.py | 84 ----- backend/tests/test_tierlist.py | 42 +++ backend/tests/test_traits.py | 98 +++++ backend/tft/api/app.py | 344 ++++++++++++++++- backend/tft/cli.py | 132 ++++++- backend/tft/constants/set17.toml | 5 +- backend/tft/constants/set18.toml | 88 +++++ backend/tft/db.py | 24 +- backend/tft/matches/crawl.py | 19 +- backend/tft/matches/extract.py | 7 +- backend/tft/matches/riot.py | 43 ++- backend/tft/model/artifact.py | 124 +++++-- backend/tft/model/augments.py | 63 +++- backend/tft/model/baseline.py | 15 + backend/tft/model/calibrate.py | 40 +- backend/tft/model/carries.py | 347 +++++++++++++++++ backend/tft/model/groups.py | 223 +++++++++++ backend/tft/model/learn.py | 93 +++-- backend/tft/model/pairs.py | 156 ++++++++ backend/tft/model/rating.py | 148 ++++++++ backend/tft/model/score.py | 65 ---- backend/tft/model/statsheet.py | 186 ---------- backend/tft/model/train_rating.py | 195 ++++++++++ backend/tft/sim/arena.py | 134 +++++++ backend/tft/sim/battle.py | 450 +++++++++++++++++++++++ backend/tft/sim/combat.py | 107 ------ backend/tft/sim/game.py | 55 ++- backend/tft/sim/hexgrid.py | 88 +++++ backend/tft/sim/lineup.py | 49 +++ backend/tft/sim/player.py | 82 ++++- backend/tft/sim/pool.py | 5 +- backend/tft/sim/traits.py | 158 ++++++++ backend/tft/staticdata/fetch.py | 57 ++- backend/tft/staticdata/icons.py | 157 ++++++++ backend/tft/staticdata/metatft.py | 298 +++++++++++++++ backend/tft/staticdata/parse.py | 21 +- backend/tft/staticdata/procomps.py | 67 ++++ backend/tft/staticdata/tierlist.py | 68 ++++ backend/uv.lock | 75 ++++ frontend/src/App.vue | 248 +++++++++---- frontend/src/api.js | 7 + frontend/src/components/BattleView.vue | 220 +++++++++++ frontend/src/components/CarryPanel.vue | 134 +++++++ frontend/src/components/GroupPanel.vue | 156 ++++++++ frontend/src/components/HexGrid.vue | 191 ++++++++++ frontend/src/components/OpponentList.vue | 123 ++++--- frontend/src/components/PairPanel.vue | 140 +++++++ frontend/src/components/RolePanel.vue | 159 ++++++++ frontend/src/components/Shop.vue | 275 ++++++++------ frontend/src/components/TraitPanel.vue | 151 ++++++-- frontend/src/components/UnitChip.vue | 103 ++++-- frontend/src/store.js | 36 +- 69 files changed, 7080 insertions(+), 1165 deletions(-) create mode 100644 backend/tests/test_arena.py create mode 100644 backend/tests/test_battle.py create mode 100644 backend/tests/test_carries.py delete mode 100644 backend/tests/test_combat.py create mode 100644 backend/tests/test_fullgame.py create mode 100644 backend/tests/test_groups.py create mode 100644 backend/tests/test_hexgrid.py create mode 100644 backend/tests/test_metatft.py create mode 100644 backend/tests/test_pairs.py create mode 100644 backend/tests/test_rating.py delete mode 100644 backend/tests/test_score.py delete mode 100644 backend/tests/test_statsheet.py create mode 100644 backend/tests/test_tierlist.py create mode 100644 backend/tests/test_traits.py create mode 100644 backend/tft/constants/set18.toml create mode 100644 backend/tft/model/carries.py create mode 100644 backend/tft/model/groups.py create mode 100644 backend/tft/model/pairs.py create mode 100644 backend/tft/model/rating.py delete mode 100644 backend/tft/model/score.py delete mode 100644 backend/tft/model/statsheet.py create mode 100644 backend/tft/model/train_rating.py create mode 100644 backend/tft/sim/arena.py create mode 100644 backend/tft/sim/battle.py delete mode 100644 backend/tft/sim/combat.py create mode 100644 backend/tft/sim/hexgrid.py create mode 100644 backend/tft/sim/lineup.py create mode 100644 backend/tft/sim/traits.py create mode 100644 backend/tft/staticdata/icons.py create mode 100644 backend/tft/staticdata/metatft.py create mode 100644 backend/tft/staticdata/procomps.py create mode 100644 backend/tft/staticdata/tierlist.py create mode 100644 frontend/src/components/BattleView.vue create mode 100644 frontend/src/components/CarryPanel.vue create mode 100644 frontend/src/components/GroupPanel.vue create mode 100644 frontend/src/components/HexGrid.vue create mode 100644 frontend/src/components/PairPanel.vue create mode 100644 frontend/src/components/RolePanel.vue diff --git a/README.md b/README.md index 885d06d..09e2631 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # TFT-Simulator Trainings-Tool: TFT-Runden schnell simulieren, 1 Spieler vs. 7 Bots. -Kampf = Vergleich von Boardstärke-Scores (Regel-Baseline + gelernte Gewichte aus Endboard-Matchdaten). +Kampf = deterministische DPS-Simulation (`sim/combat.py`) auf Unit-Profilen aus +exakten Community-Dragon-Werten (`model/statsheet.py`: eHP und DPS je Unit). +`model/score.py` liefert daraus die angezeigte Boardstärke und die Kalibrierung. + +Hinweis: die aus Matchdaten gelernten Gewichte (`model/learn.py`) landen im +Artifact, werden vom Scorer aber derzeit **nicht** gelesen. ## Setup diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 06d5d9d..56d96d5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,6 +7,8 @@ dependencies = [ "fastapi", "uvicorn", "requests", + # nur fürs Training der Boardbewertung; die Auswertung läuft ohne + "numpy", ] [dependency-groups] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0d2aaee..d5b8652 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -58,7 +58,8 @@ def test_state_shape(client): "opponents", "items", "over", "log", "shop_odds", "shop_locked"): assert key in state assert sum(state["shop_odds"]) == 100 - assert state["player"]["score"] == 0.0 + assert state["player"]["off"] == 0.0 + assert state["player"]["def"] == 0.0 assert all(o["board"] is not None for o in state["opponents"]) @@ -76,3 +77,84 @@ def test_upgrade_hint(): assert upgrade_hint(game, "U1_0") == 3 game.bench = [{"api_name": "U1_0", "stars": 3, "items": []}] assert upgrade_hint(game, "U1_0") == 0 + + +@pytest.fixture +def group_client(monkeypatch): + static = make_static() + static["meta"]["patch"] = "test" + art = artifact_mod.build(static) + a, b = list(art["static"]["units"])[:2] + # Schlüssel sind "api@itemzahl" — die Karte zeigt die Items mit an. + # Schlüssel sind "api@sterne@itemzahl". Zwei Gruppen teilen sich + # denselben Carry — nur die bessere darf durchkommen. + art["groups"] = { + "2": {f"{a}@2@3|{b}@2@0": {"edge": 1.2, "boards": 400}, + f"{a}@2@3|{b}@1@1": {"edge": 0.9, "boards": 700}, + f"{a}@1@0|{b}@2@3": {"edge": 0.4, "boards": 900}}, + "boards": 5000, + } + cfg = load_constants(17) + monkeypatch.setattr(app_mod, "_load_game_deps", lambda: (art, cfg)) + return TestClient(app_mod.app) + + +def test_groups_endpoint(group_client): + """Der Gruppen-Reiter braucht Name, Kosten und Icon je Unit.""" + r = group_client.get("/api/groups?size=2&limit=3") + assert r.status_code == 200 + body = r.json() + assert body["size"] == 2 and body["boards"] == 5000 + assert 0 < len(body["groups"]) <= 3 + first = body["groups"][0] + assert len(first["units"]) == 2 + assert all({"name", "cost", "icon", "splash", "items", "stars", "traits", "tier"} + <= set(u) for u in first["units"]) + assert [u["items"] for u in first["units"]] == [3, 0] + assert [u["stars"] for u in first["units"]] == [2, 2] + # Je Carry nur eine Gruppe: a@3 kommt einmal vor, dann b@3. + carries = [g["units"][0]["name"] for g in body["groups"]] + assert len(carries) == len(set(carries)) + # Nach Vorsprung sortiert, absteigend. + edges = [g["edge"] for g in body["groups"]] + assert edges == sorted(edges, reverse=True) + assert group_client.get("/api/groups?size=2&limit=1").json()["groups"] == [first] + + +def test_groups_without_data(client): + # Ohne trainiertes Artefakt gibt es nichts zu zeigen — 404 statt leerer Liste. + assert client.get("/api/groups?size=2").status_code == 404 + + +def test_groups_unknown_size(group_client): + assert group_client.get("/api/groups?size=7").status_code == 404 + + +def test_roles_endpoint(monkeypatch): + """Beste Unit je Rolle und Kostenstufe, mit den Items dieses Champions.""" + static = make_static() + static["meta"]["patch"] = "test" + art = artifact_mod.build(static) + api = next(a for a, u in art["static"]["units"].items() if "Tank" in (u.get("role") or "")) + item = next(iter(art["static"]["items"])) + art["groups"] = {"solo": {f"{api}@2@3": {"edge": 0.5, "boards": 300}}, + "unit_items": {f"{api}|{item}": {"edge": 0.3, "boards": 400}, + f"{api}|schlecht": {"edge": -0.1, "boards": 400}}, + "boards": 9000} + art["unit_tiers"] = {api: {"tier": "S", "strength": 0.4, "boards": 1100}} + monkeypatch.setattr(app_mod, "_load_game_deps", lambda: (art, load_constants(17))) + body = TestClient(app_mod.app).get("/api/roles").json() + tank = next(r for r in body["roles"] if r["label"] == "Tank") + entry = next(c for c in tank["costs"] if c["unit"]) + # Buchstabe und Zahl kommen aus derselben Quelle. + assert entry["unit"]["tier"] == "S" + assert entry["unit"]["edge"] == 0.4 + # Items haengen am Champion, Negatives ist keine Empfehlung. + assert [i["edge"] for i in entry["unit"]["items"]] == [0.3] + assert {"name", "cost", "traits", "splash"} <= set(entry["unit"]) + # Kostenstufen ohne Daten bleiben leer statt zu raten. + assert any(c["unit"] is None for c in tank["costs"]) + + +def test_roles_without_data(client): + assert client.get("/api/roles").status_code == 404 diff --git a/backend/tests/test_arena.py b/backend/tests/test_arena.py new file mode 100644 index 0000000..2f736c7 --- /dev/null +++ b/backend/tests/test_arena.py @@ -0,0 +1,103 @@ +"""Arena: Boards auf das Kampffeld, eigene Positionen, leere Seiten.""" + +import random + +from tft.sim.arena import BOARD_COLS +from tft.sim import arena, hexgrid + +STATS = {"hp": 800, "armor": 20, "magicResist": 20, "damage": 50, + "damageByStar": [0, 50, 100, 200], "attackSpeed": 1.0, "range": 1, + "mana": 0, "initialMana": 0, "critChance": 0.0, "critMultiplier": 1.4} +ARTIFACT = {"static": { + "units": {"A": {"traits": [], "stats": STATS}, + "B": {"traits": [], "stats": {**STATS, "range": 4}}}, + "items": {"HP": {"effects": {"Health": 500}}}, + "traits": {}, +}} + + +def test_front_row_of_both_sides_faces_the_other(): + """Reihe 0 des Spielers ist die vorderste — sonst laufen alle aneinander vorbei.""" + a = arena.from_hex(3, "a") + b = arena.from_hex(3, "b") + assert hexgrid.distance((a["col"], a["row"]), (b["col"], b["row"])) == 1 + + +def test_back_row_is_furthest_away(): + a = arena.from_hex(3 + 3 * BOARD_COLS, "a") + b = arena.from_hex(3 + 3 * BOARD_COLS, "b") + assert hexgrid.distance((a["col"], a["row"]), (b["col"], b["row"])) == 7 + + +def test_own_positions_are_used(): + board = [{"api_name": "A", "stars": 1, "hex": 3}] + out = arena.placed(board, "a", ARTIFACT["static"]["units"]) + assert (out[0]["col"], out[0]["row"]) == (3, 3) + + +def test_boards_without_positions_fall_back_to_the_lineup(): + """Endboards aus der Riot-API tragen keine Felder.""" + board = [{"api_name": "A", "stars": 1}, {"api_name": "B", "stars": 1}] + out = arena.placed(board, "a", ARTIFACT["static"]["units"]) + assert all("col" in e and "row" in e for e in out) + # Nahkämpfer vorne, Fernkämpfer hinten. + front = next(e for e in out if e["api_name"] == "A") + back = next(e for e in out if e["api_name"] == "B") + assert front["row"] > back["row"] + + +def test_two_units_never_share_a_cell(): + board = [{"api_name": "A", "stars": 1, "hex": 3}, + {"api_name": "A", "stars": 1, "hex": 3}] + out = arena.placed(board, "a", ARTIFACT["static"]["units"]) + assert len({(e["col"], e["row"]) for e in out}) == 2 + + +def test_an_empty_board_loses_at_once(): + board = [{"api_name": "A", "stars": 1, "hex": 0}] + res, log = arena.fight([], board, ARTIFACT, random.Random(1)) + assert res.winner == "b" and res.frames == 0 and log is None + res, _ = arena.fight(board, [], ARTIFACT, random.Random(1)) + assert res.winner == "a" + assert arena.fight([], [], ARTIFACT, random.Random(1))[0].winner is None + + +def test_items_reach_the_fighters(): + board = [{"api_name": "A", "stars": 1, "hex": 0, "items": ["HP"]}] + plain = arena.side([{"api_name": "A", "stars": 1, "hex": 0}], "a", ARTIFACT) + armed = arena.side(board, "a", ARTIFACT) + assert armed[0].max_hp == plain[0].max_hp + 500 + + +def test_the_bigger_board_wins(): + big = [{"api_name": "A", "stars": 1, "hex": h} for h in range(4)] + small = [{"api_name": "A", "stars": 1, "hex": 0}] + assert arena.fight(big, small, ARTIFACT, random.Random(1))[0].winner == "a" + + +def test_replay_returns_a_playable_log(): + """Feldmaße, Kämpfer und Ereignisse — mehr braucht die Anzeige nicht.""" + a = [{"api_name": "A", "stars": 1, "hex": h} for h in range(3)] + b = [{"api_name": "A", "stars": 1, "hex": h} for h in range(2)] + _, log = arena.fight(a, b, ARTIFACT, random.Random(1), record=True) + assert log["cols"] == hexgrid.COLS and log["rows"] == hexgrid.ROWS + assert len(log["units"]) == 5 + assert {u["slot"] for u in log["units"]} == set(range(5)) + kinds = {e["e"] for e in log["events"]} + assert "spawn" in kinds and "hit" in kinds + assert log["winner"] == "a" + + +def test_replay_is_none_without_a_fight(): + _, log = arena.fight([], [{"api_name": "A", "stars": 1, "hex": 0}], + ARTIFACT, random.Random(1), record=True) + assert log is None + + +def test_replay_matches_the_fight(): + """Sonst zeigt die Anzeige etwas anderes als das Spiel gewertet hat.""" + a = [{"api_name": "A", "stars": 1, "hex": h} for h in range(3)] + b = [{"api_name": "B", "stars": 1, "hex": h} for h in range(3)] + res, log = arena.fight(a, b, ARTIFACT, random.Random(3), record=True) + plain, _ = arena.fight(a, b, ARTIFACT, random.Random(3)) + assert log["winner"] == res.winner == plain.winner diff --git a/backend/tests/test_battle.py b/backend/tests/test_battle.py new file mode 100644 index 0000000..002fdac --- /dev/null +++ b/backend/tests/test_battle.py @@ -0,0 +1,206 @@ +"""Kampf: Minderung, Reichweite, Bewegung, Zaubern, Determinismus.""" + +import random + +from tft.sim import battle as B +from tft.sim import hexgrid as H + +UNITS = { + "NAH": {"stats": {"hp": 1000, "armor": 0, "magicResist": 0, "damage": 100, + "damageByStar": [0, 100, 200, 400], "attackSpeed": 1.0, + "range": 1, "mana": 0, "initialMana": 0, + "critChance": 0.0, "critMultiplier": 1.4}}, + "FERN": {"stats": {"hp": 500, "armor": 0, "magicResist": 0, "damage": 50, + "damageByStar": [0, 50, 100, 200], "attackSpeed": 1.0, + "range": 4, "mana": 0, "initialMana": 0, + "critChance": 0.0, "critMultiplier": 1.4}}, + "PANZER": {"stats": {"hp": 1000, "armor": 100, "magicResist": 100, + "damage": 10, "damageByStar": [0, 10, 20, 40], + "attackSpeed": 1.0, "range": 1, "mana": 0, + "initialMana": 0, "critChance": 0.0, + "critMultiplier": 1.4}}, + "MAGIER": {"stats": {"hp": 600, "armor": 0, "magicResist": 0, "damage": 10, + "damageByStar": [0, 10, 20, 40], "attackSpeed": 1.0, + "range": 4, "mana": 30, "initialMana": 0, + "critChance": 0.0, "critMultiplier": 1.4}, + "spell_damage": [0, 300, 600, 1200], + "spell_damage_type": "magic"}, +} + + +def make(key, side, col, row, stars=1): + f = B.to_fighter({"api_name": key, "stars": stars, "col": col, "row": row}, + side, UNITS) + assert f is not None + return f + + +def test_mitigation_halves_at_hundred_armor(): + assert B.mitigate(100, 0) == 100 + assert B.mitigate(100, 100) == 50 + assert B.mitigate(100, 300) == 25 + + +def test_stars_raise_health_and_damage(): + one, two = make("NAH", "a", 0, 0, 1), make("NAH", "a", 0, 0, 2) + assert two.max_hp > one.max_hp + assert two.attack_damage == 2 * one.attack_damage + + +def test_the_stronger_side_wins(): + a = [make("NAH", "a", i, 3) for i in range(3)] + b = [make("NAH", "b", i, 4) for i in range(1)] + assert B.fight(a, b, random.Random(1)).winner == "a" + + +def test_armour_buys_time(): + """Gleiches Leben, aber einer mindert — der hält doppelt so lange durch.""" + def survives(defender): + a = [make(defender, "a", 3, 3)] + b = [make("NAH", "b", 3, 4)] + return B.fight(a, b, random.Random(1), max_seconds=120).seconds + + assert survives("PANZER") > 1.8 * survives("NAH") + + +def test_ranged_units_close_the_gap_and_shoot_first(): + """Der Fernkämpfer steht außer Reichweite und muss trotzdem treffen.""" + a = [make("FERN", "a", 3, 0)] + b = [make("NAH", "b", 3, 7)] + res = B.fight(a, b, random.Random(1), max_seconds=60) + # Beide bewegen sich; der Fernkämpfer trifft aus vier Feldern. + assert res.fighters[0].damage_done > 0 + + +def test_units_move_towards_the_enemy(): + one = make("NAH", "a", 3, 0) + foe = make("NAH", "b", 3, 7) + before = H.distance(one.pos, foe.pos) + B._step_towards(one, foe, {one.pos, foe.pos}) + assert H.distance(one.pos, foe.pos) == before - 1 + + +def test_occupied_cells_are_not_entered(): + one = make("NAH", "a", 3, 3) + foe = make("NAH", "b", 3, 4) + taken = {one.pos, foe.pos} | set(H.neighbours(3, 3)) + B._step_towards(one, foe, taken) + assert one.pos == (3, 3) + + +def test_mana_fills_from_attacks_and_then_casts(): + caster = make("MAGIER", "a", 3, 3) + foe = make("NAH", "b", 3, 4) + assert caster.mana == 0 + B.fight([caster], [foe], random.Random(1), max_seconds=10) + assert caster.casts >= 1 + + +def test_units_without_mana_never_cast(): + """NAH hat keine Mana-Kosten — sonst zauberte er im ersten Tick.""" + one = make("NAH", "a", 3, 3) + foe = make("PANZER", "b", 3, 4) + B.fight([one], [foe], random.Random(1), max_seconds=10) + assert one.casts == 0 + + +def test_same_seed_same_outcome(): + def run(seed): + a = [make("NAH", "a", i, 3) for i in range(3)] + b = [make("FERN", "b", i, 4) for i in range(3)] + res = B.fight(a, b, random.Random(seed)) + return res.winner, res.seconds, len(res.survivors_a) + + assert run(7) == run(7) + + +def test_a_fight_always_ends(): + """Zwei Panzer ohne Schaden dürfen nicht ewig laufen.""" + a = [make("PANZER", "a", 3, 3)] + b = [make("PANZER", "b", 3, 4)] + res = B.fight(a, b, random.Random(1), max_seconds=5) + assert res.seconds <= 5 + + +def test_unknown_units_are_skipped(): + assert B.to_fighter({"api_name": "GIBTS_NICHT"}, "a", UNITS) is None + + +AOE = {"stats": {"hp": 600, "armor": 0, "magicResist": 0, "damage": 10, + "damageByStar": [0, 10, 20, 40], "attackSpeed": 1.0, + "range": 4, "mana": 30, "initialMana": 30, + "critChance": 0.0, "critMultiplier": 1.4}, + "spell_damage": [0, 300, 600, 1200], "spell_damage_type": "magic", + "ability_vars": {"AoEHexRange": [0, 2, 2, 2]}} + + +def test_area_spells_hit_the_neighbours(): + """Radius aus den Fähigkeits-Variablen: alle im Umkreis nehmen Schaden.""" + UNITS["FLAECHE"] = AOE + try: + caster = make("FLAECHE", "a", 3, 3) + near = [make("NAH", "b", c, 4) for c in (2, 3, 4)] + B.fight([caster], near, random.Random(1), max_seconds=1) + assert all(f.hp < f.max_hp for f in near) + finally: + del UNITS["FLAECHE"] + + +def test_single_target_spells_stay_single(): + caster = make("MAGIER", "a", 3, 3) + near = [make("NAH", "b", c, 4) for c in (2, 3, 4)] + B.fight([caster], near, random.Random(1), max_seconds=1) + assert sum(1 for f in near if f.hp < f.max_hp) == 1 + + +def test_spell_shape_reads_radius_and_count(): + assert B.spell_shape(AOE, 2) == (2, 1) + assert B.spell_shape({"ability_vars": {"NumEnemiesHit": [0, 4, 4, 4]}}, 2) \ + == (0, 4) + assert B.spell_shape({}, 2) == (0, 1) + + +def test_physical_spells_scale_with_attack_damage(): + """Ein Carry mit AD-Items soll auch härter zaubern.""" + UNITS["AD_ZAUBER"] = { + "stats": {**UNITS["MAGIER"]["stats"], "initialMana": 30}, + "spell_damage": [0, 300, 600, 1200], "spell_damage_type": "physical"} + ITEMS = {"AD": {"effects": {"AD": 1.0}}} + try: + plain = make("AD_ZAUBER", "a", 3, 3) + armed = make("AD_ZAUBER", "a", 3, 3) + B.apply_items(armed, ["AD"], ITEMS) + foes = [make("PANZER", "b", 3, 4), make("PANZER", "b", 3, 4)] + B.fight([plain], [foes[0]], random.Random(1), max_seconds=0.5) + B.fight([armed], [foes[1]], random.Random(1), max_seconds=0.5) + assert armed.damage_done > 1.5 * plain.damage_done + finally: + del UNITS["AD_ZAUBER"] + + +def test_the_log_records_spawn_hit_and_death(): + a = [make("NAH", "a", 3, 3)] + b = [make("FERN", "b", 3, 4)] + res = B.fight(a, b, random.Random(1), max_seconds=30, record=True) + kinds = {e["e"] for e in res.events} + assert {"spawn", "hit", "die"} <= kinds + # Zwei Kämpfer, zwei Spawns, und jeder Eintrag trägt einen Tick. + assert sum(1 for e in res.events if e["e"] == "spawn") == 2 + assert all("t" in e for e in res.events) + + +def test_the_log_stays_off_by_default(): + """Die Auswertung läuft millionenfach — dort darf nichts mitgeschrieben werden.""" + a = [make("NAH", "a", 3, 3)] + b = [make("NAH", "b", 3, 4)] + assert B.fight(a, b, random.Random(1), max_seconds=5).events == [] + + +def test_recording_does_not_change_the_outcome(): + def run(record): + a = [make("NAH", "a", i, 3) for i in range(3)] + b = [make("FERN", "b", i, 4) for i in range(3)] + res = B.fight(a, b, random.Random(5), record=record) + return res.winner, res.seconds + + assert run(False) == run(True) diff --git a/backend/tests/test_carries.py b/backend/tests/test_carries.py new file mode 100644 index 0000000..951df95 --- /dev/null +++ b/backend/tests/test_carries.py @@ -0,0 +1,117 @@ +"""Carries: Kontrollen, Zielstufe, Zerlegung.""" + +from tft.model import carries + +UNITS = {a: {"cost": 3, "traits": ["T"]} for a in ("A", "B", "CARRY", "TANK")} +UNITS["TEUER"] = {"cost": 5, "traits": ["T"]} +TRAITS = {"T": {"breakpoints": [{"min_units": 1}, {"min_units": 3}]}} + + +def board(place, level, items_total, *ents): + """ents: (api, sterne, itemzahl)""" + return {"place": place, "level": level, "items": items_total, + "entities": list(ents)} + + +def prepared(boards): + return carries.prepare(boards, UNITS, TRAITS) + + +def test_other_three_stars_are_part_of_the_comparison_group(): + """Wer eine Unit auf 3★ hat, hatte Gold übrig — das Board ist deshalb gut. + + Die eigenen 3★ zählen dabei nicht mit, sonst würde man den Effekt + misst, den man erklären will. + """ + b = prepared([board(1, 9, 3, ("A", 3, 3), ("B", 3, 0))])[0] + assert carries.cell(b, 3) == (9, 3, 1) # eigene 3★ abgezogen + assert carries.cell(b, 2) == (9, 3, 2) # aus Sicht einer 2★-Unit beide + + +def test_reroll_carries_are_shown_on_three_stars(): + """Auf 2★ negativ, auf 3★ positiv: so spielt man diesen Champion.""" + boards = prepared([board(6, 9, 3, ("A", 2, 3)) for _ in range(60)] + + [board(1, 9, 3, ("A", 3, 3)) for _ in range(60)]) + base = carries.baseline(boards) + out = carries.values(boards, base, UNITS, min_boards=10) + assert out["A"]["stars"] == 3 and out["A"]["edge"] > 0 + + +def test_expensive_units_are_never_shown_on_three_stars(): + """Ein 5-Kosten auf 3★ heißt: das Spiel war schon gewonnen. + + In den Daten holen 5-Kosten auf 3★ in 98 % der Fälle Platz 1. + """ + boards = prepared([board(6, 9, 3, ("TEUER", 2, 3)) for _ in range(60)] + + [board(1, 9, 3, ("TEUER", 3, 3)) for _ in range(60)]) + base = carries.baseline(boards) + out = carries.values(boards, base, UNITS, min_boards=10) + assert out["TEUER"]["stars"] == 2 + + +def test_units_below_the_rate_are_kept_but_not_carries(): + """Ein Tank ohne Items bleibt in der Tabelle — er ist der Maßstab. + + Der Buchstabe im Reiter vergleicht mit allen Units, nicht nur mit + den Carries. + """ + boards = prepared([board(1, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0)) + for _ in range(60)] + + [board(4, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 3)) + for _ in range(20)]) + base = carries.baseline(boards) + out = carries.values(boards, base, UNITS, min_boards=10) + assert out["CARRY"]["carry"] and not out["TANK"]["carry"] + + +def test_rare_units_are_dropped(): + boards = prepared([board(1, 9, 3, ("A", 2, 3)) for _ in range(9)]) + base = carries.baseline(boards) + assert carries.values(boards, base, UNITS, min_boards=10) == {} + + +def test_reasons_separate_unit_from_the_rest(): + """Zwei Carries, gleiche Traits, aber einer steht neben besseren Units. + + Der Unterschied muss im Topf "rest" landen, nicht in "unit". + """ + boards = prepared( + [board(2, 9, 3, ("A", 2, 3), ("TANK", 2, 0)) for _ in range(400)] + + [board(2, 9, 3, ("B", 2, 3), ("CARRY", 2, 0)) for _ in range(400)] + + [board(7, 9, 3, ("TANK", 2, 3)) for _ in range(400)] + + [board(1, 9, 3, ("CARRY", 2, 3)) for _ in range(400)]) + base = carries.baseline(boards) + beta, idx, own_of = carries._weights(boards, base, UNITS, carries.RIDGE) + why = carries.reasons(boards, base, own_of, idx, beta, min_boards=100) + a, b = why[("A", 2)], why[("B", 2)] + # B steht neben CARRY (gut), A neben TANK (schlecht). + assert b["rest"] > a["rest"] + + +def test_build_is_empty_without_data(): + assert carries.build([], UNITS, TRAITS, min_boards=10) == {} + + +def test_enabler_measures_the_same_carry_with_and_without(): + """Stützt die Unit fremde Carries — oder steht sie nur daneben? + + CARRY läuft mit B besser als ohne. B trägt selbst nie Items, kann den + Vorsprung also nicht selbst erklären. + """ + boards = prepared( + [board(2, 9, 3, ("CARRY", 2, 3), ("B", 2, 0)) for _ in range(400)] + + [board(6, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0)) for _ in range(400)]) + base = carries.baseline(boards) + lift = carries.enabler_edge(boards, base, UNITS, min_pair=100, min_total=100) + assert lift["B"] > 0 > lift["TANK"] + + +def test_supported_is_relative_to_comparable_boards(): + """Ein Carry mit starken Stützern liegt über dem Üblichen.""" + boards = prepared( + [board(2, 9, 3, ("A", 2, 3), ("B", 2, 0)) for _ in range(400)] + + [board(6, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0)) for _ in range(400)]) + base = carries.baseline(boards) + lift = {"B": 0.4, "TANK": -0.4} + prop = carries.supported(boards, base, lift, min_boards=100) + assert prop[("A", 2)] > prop[("CARRY", 2)] diff --git a/backend/tests/test_combat.py b/backend/tests/test_combat.py deleted file mode 100644 index 405b322..0000000 --- a/backend/tests/test_combat.py +++ /dev/null @@ -1,92 +0,0 @@ -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 diff --git a/backend/tests/test_fullgame.py b/backend/tests/test_fullgame.py new file mode 100644 index 0000000..3e32ba5 --- /dev/null +++ b/backend/tests/test_fullgame.py @@ -0,0 +1,257 @@ +"""Ein ganzes Spiel durchspielen und dabei die Regeln prüfen. + +Die Einzeltests decken jede Funktion für sich ab. Hier geht es um das +Zusammenspiel über 30 Runden: bleibt Gold nicht-negativ, verschwinden +keine Units, endet das Spiel, stimmt der Pool. +""" + +import random + +import pytest + +from tft.constants.loader import load_constants +from tft.model import artifact as artifact_mod +from tft.sim import player as sim_player +from tft.sim.game import Game + +SET = 18 + + +@pytest.fixture(scope="module") +def deps(): + try: + return artifact_mod.load(SET), load_constants(SET) + except (FileNotFoundError, KeyError): + pytest.skip("kein Artefakt für Set 18 gebaut") + + +def act(game): + """Wie ein einfacher Spieler handeln: Level kaufen, Shop leeren, aufstellen. + + Ohne das Level-Kaufen bleibt die Bank nach wenigen Runden voll und das + Spiel prüft nichts mehr — Gold staut sich, das Board wächst nicht. + """ + while game.player.gold >= 12: + try: + game.buy_xp() + except Exception: + break + for slot in range(len(game.player.shop or [])): + try: + game.buy(slot) + except Exception: + pass + for _ in range(len(game.player.bench)): + try: + game.move("bench", 0) + except Exception: + break + # Volle Bank blockiert jeden weiteren Kauf. + while len(game.player.bench) >= sim_player.BENCH_SIZE: + game.sell("bench", 0) + + +def play(deps, seed, steps=60): + artifact, cfg = deps + game = Game(artifact, cfg, seed=seed) + seen = [] + for _ in range(steps): + if game.over: + break + act(game) + seen.append({ + "round": game.round["label"], + "hp": game.player.hp, + "gold": game.player.gold, + "level": game.player.level, + "board": len(game.player.board), + "bench": len(game.player.bench), + }) + game.step() + return game, seen + + +def test_a_game_runs_to_the_end(deps): + game, seen = play(deps, seed=1) + assert len(seen) > 5 + assert game.over or game.round["label"] + + +def test_gold_never_goes_negative(deps): + _, seen = play(deps, seed=2) + assert all(s["gold"] >= 0 for s in seen) + + +def test_health_only_falls(deps): + _, seen = play(deps, seed=3) + hps = [s["hp"] for s in seen] + assert hps == sorted(hps, reverse=True) + + +def test_the_board_never_exceeds_the_level(deps): + """Mehr Units als Level wäre ein Regelbruch.""" + artifact, cfg = deps + game = Game(artifact, cfg, seed=4) + for _ in range(40): + if game.over: + break + act(game) + assert len(game.player.board) <= game.player.level + game.step() + + +def test_the_bench_has_a_limit(deps): + artifact, cfg = deps + game = Game(artifact, cfg, seed=5) + for _ in range(30): + if game.over: + break + act(game) + assert len(game.player.bench) <= sim_player.BENCH_SIZE + game.step() + + +def test_every_unit_sits_on_its_own_hex(deps): + artifact, cfg = deps + game = Game(artifact, cfg, seed=6) + for _ in range(40): + if game.over: + break + act(game) + hexes = [u.get("hex") for u in game.player.board] + assert len(hexes) == len(set(hexes)), "zwei Units auf demselben Feld" + assert all(h is not None for h in hexes) + game.step() + + +def test_the_same_seed_gives_the_same_game(deps): + """Ohne das lässt sich kein Kampf nachspielen und nichts vergleichen.""" + _, first = play(deps, seed=42, steps=20) + _, again = play(deps, seed=42, steps=20) + assert first == again + + +def test_players_drop_out_over_time(deps): + artifact, cfg = deps + game = Game(artifact, cfg, seed=7) + for _ in range(60): + if game.over: + break + game.step() + alive = [p for p in game.players if p.hp > 0] + assert len(alive) < len(game.players) + + +def test_fights_produce_a_result(deps): + """Jede PvP-Runde muss einen Sieger oder ein Unentschieden liefern.""" + artifact, cfg = deps + game = Game(artifact, cfg, seed=8) + seen = 0 + for _ in range(30): + if game.over: + break + before = [p.hp for p in game.players] + game.step() + after = [p.hp for p in game.players] + if before != after: + seen += 1 + assert seen > 0, "in 30 Runden hat niemand Leben verloren" + + +def test_the_pool_stays_complete(deps): + """Jede Kopie eines Champions ist entweder im Pool oder auf einem Board. + + Verschwinden welche, sind Käufe oder Verkäufe kaputt. Auftauchen + dürfen auch keine, sonst wäre der Pool unendlich. + """ + from collections import Counter + + artifact, cfg = deps + game = Game(artifact, cfg, seed=9) + sizes = cfg["pool"]["sizes"] + units = artifact["static"]["units"] + start = {api: sizes[u["cost"] - 1] for api, u in units.items() + if 1 <= u["cost"] <= len(sizes)} + + def total(): + held = Counter() + for p in game.players: + for u in p.board + p.bench: + held[u["api_name"]] += 3 ** (u["stars"] - 1) + return {api: game.pool.available(api) + held[api] for api in start} + + for _ in range(30): + if game.over: + break + act(game) + wrong = {api: (start[api], now) for api, now in total().items() + if now != start[api]} + assert not wrong, f"Kopien verschwunden oder erfunden: {list(wrong)[:3]}" + game.step() + + +def test_a_full_bench_blocks_buying(deps): + """Sonst kauft man Units ins Nichts.""" + artifact, cfg = deps + game = Game(artifact, cfg, seed=10) + for _ in range(40): + if game.over: + break + if len(game.player.bench) >= sim_player.BENCH_SIZE and game.player.shop: + before = len(game.player.bench) + with pytest.raises(Exception): + game.buy(0) + assert len(game.player.bench) == before + return + act_without_selling(game) + game.step() + pytest.skip("Bank wurde in 40 Runden nie voll") + + +def act_without_selling(game): + for slot in range(len(game.player.shop or [])): + try: + game.buy(slot) + except Exception: + pass + + +def test_the_players_fight_is_recorded(deps): + """Ohne Protokoll zeigt die Anzeige nichts oder den falschen Kampf.""" + artifact, cfg = deps + game = Game(artifact, cfg, seed=11) + for _ in range(20): + if game.over: + break + act(game) + game.step() + if game.last_battle: + log = game.last_battle + assert log["units"] and log["events"] + # Der Spieler steht in der Anzeige immer auf Seite "a". + mine = [u for u in log["units"] if u["side"] == "a"] + assert len(mine) == len(game.player.board) or game.over + return + pytest.skip("in 20 Runden kam kein Kampf mit Board zustande") + + +def test_the_player_is_always_side_a(deps): + """Sonst zeichnet die Anzeige die eigenen Units beim Gegner.""" + artifact, cfg = deps + game = Game(artifact, cfg, seed=12) + seen = 0 + for _ in range(30): + if game.over: + break + act(game) + game.step() + log = game.last_battle + if not log: + continue + seen += 1 + rows = [e["row"] for e in log["events"] + if e["e"] == "spawn" + and next(u["side"] for u in log["units"] if u["slot"] == e["u"]) == "a"] + # Eigene Units starten in der eigenen Hälfte, also Reihe 0 bis 3. + assert all(r < log["rows"] // 2 for r in rows), rows + assert seen > 0 diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py index 9cde7ea..ead9bce 100644 --- a/backend/tests/test_game.py +++ b/backend/tests/test_game.py @@ -3,7 +3,7 @@ import pytest from tft.constants.loader import load_constants from tft.model import artifact as artifact_mod from tft.sim.autoplay import play_afk, play_econ -from tft.sim.game import Game, InvalidAction +from tft.sim.game import MAX_ITEMS_PER_UNIT, Game, InvalidAction from tests.test_sim import make_static @@ -229,7 +229,20 @@ def test_natural_level_progression(art, cfg): assert checks["3-2"] == (5, 0) +def test_no_augments_are_offered(art, cfg): + """Augments sind per Config abgeschaltet — weder Spieler noch Bots bekommen welche.""" + game = Game(art, cfg, seed=2) + for _ in range(24): + assert not game.player.augment_offer + assert all(not p.augments for p in game.players) + if game.over: + break + game.step() + + def test_augment_offer_single_tier(art, cfg): + # Die Mechanik bleibt geprüft, auch wenn sie ausgeliefert deaktiviert ist. + cfg = {**cfg, "rounds": {**cfg["rounds"], "augment_rounds": ["2-1"]}} 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}, @@ -255,6 +268,89 @@ def test_equip_crafts_completed_item(art, cfg): assert game.board[0]["items"] == ["COMPLETED", "C_A"] +def test_merge_keeps_surplus_items_and_extra_copy(art, cfg): + """Verschmelzen darf weder Items vernichten noch die 4. Kopie schlucken.""" + from tft.sim import player + + game = Game(art, cfg, seed=1) + game.items = [] + game.board = [{"api_name": "U1_0", "stars": 1, "items": ["I_A", "I_B"]}, + {"api_name": "U1_0", "stars": 1, "items": ["I_C", "I_D"]}] + game.bench = [{"api_name": "U1_0", "stars": 1, "items": ["I_E", "I_F"]}, + {"api_name": "U1_0", "stars": 1, "items": []}] + player.merge(game.player, "U1_0", 1) + + on_units = [i for u in game.board + game.bench for i in u["items"]] + assert sorted(on_units + game.items) == ["I_A", "I_B", "I_C", "I_D", "I_E", "I_F"] + assert len(on_units) == MAX_ITEMS_PER_UNIT + # 3 Kopien verschmelzen, die 4. bleibt als eigene 1★-Unit stehen. + stars = sorted(u["stars"] for u in game.board + game.bench) + assert stars == [1, 2] + + +def test_place_on_hex(art, cfg): + from tft.sim.player import BOARD_HEXES + + game = Game(art, cfg, seed=1) + game.level = 3 + game.bench = [{"api_name": "U1_0", "stars": 1, "items": [], "hex": None}, + {"api_name": "U2_0", "stars": 1, "items": [], "hex": None}] + game.board = [] + + game.place("bench", 0, 12) + assert [(u["api_name"], u["hex"]) for u in game.board] == [("U1_0", 12)] + + # Auf ein belegtes Hex ziehen -> der Belegte wandert auf die Bank. + game.place("bench", 0, 12) + assert [(u["api_name"], u["hex"]) for u in game.board] == [("U2_0", 12)] + assert game.bench[0]["api_name"] == "U1_0" and game.bench[0]["hex"] is None + + # Innerhalb des Felds verschieben. + game.place("board", 0, 5) + assert game.board[0]["hex"] == 5 + + with pytest.raises(InvalidAction): + game.place("board", 0, BOARD_HEXES) + + +def test_ranged_units_are_seated_in_the_back(): + """Automatisches Aufstellen: Nahkämpfer nach vorn, Fernkämpfer nach hinten.""" + from tft.sim.player import (BOARD_COLS, BOARD_ROWS, PlayerState, fill_board) + + ranged_art = {"static": {"units": { + "MELEE": {"cost": 1, "stats": {"range": 1}}, + "RANGED": {"cost": 1, "stats": {"range": 4}}, + }}} + p = PlayerState(name="t", archetype="x", hp=100, gold=0, level=4) + p.bench = [{"api_name": "RANGED", "stars": 1, "items": []}, + {"api_name": "MELEE", "stars": 1, "items": []}] + fill_board(p, ranged_art) + rows = {u["api_name"]: u["hex"] // BOARD_COLS for u in p.board} + assert rows["MELEE"] == 0 + assert rows["RANGED"] == BOARD_ROWS - 1 + + +def test_place_swaps_within_board(art, cfg): + game = Game(art, cfg, seed=1) + game.level = 3 + game.board = [{"api_name": "U1_0", "stars": 1, "items": [], "hex": 0}, + {"api_name": "U2_0", "stars": 1, "items": [], "hex": 9}] + game.bench = [] + game.place("board", 0, 9) + assert {u["api_name"]: u["hex"] for u in game.board} == {"U1_0": 9, "U2_0": 0} + + +def test_autofill_assigns_unique_hexes(art, cfg): + game = Game(art, cfg, seed=4) + for _ in range(6): + game.step() + for p in game.players: + hexes = [u["hex"] for u in p.board] + assert all(h is not None for h in hexes) + assert len(set(hexes)) == len(hexes) + assert all(u.get("hex") is None for u in p.bench) + + def test_loot_drops_components(art, cfg): comp_art = {**art, "component_pool": ["C_A", "C_B"]} game = Game(comp_art, cfg, seed=2) diff --git a/backend/tests/test_groups.py b/backend/tests/test_groups.py new file mode 100644 index 0000000..097559d --- /dev/null +++ b/backend/tests/test_groups.py @@ -0,0 +1,197 @@ +"""Gruppen: Kontrolle nach Level und Item-Menge, Schwellen, Schlüsselformat.""" + +from tft.model import groups + + +def board(place, level, *units): + """units: (api_name, itemzahl) — Sterne sind hier immer 2.""" + return {"place": place, "level": level, + "items": sum(n for _, n in units), + "entities": sorted((api, 2, n) for api, n in units)} + + +def test_baseline_splits_by_level_and_items(): + boards = [board(1, 9, ("A", 3)), board(3, 9, ("B", 3)), + board(7, 5, ("A", 0)), board(8, 5, ("B", 0))] + base = groups.baseline(boards) + assert base[(9, 3)] == 2.0 + assert base[(5, 0)] == 7.5 + + +def test_edge_measures_against_comparable_boards(): + """Zwei Boards auf Level 9 mit gleichen Items: nur der Unterschied zählt.""" + boards = ([board(1, 9, ("A", 3), ("B", 3)) for _ in range(40)] + + [board(7, 9, ("C", 3), ("D", 3)) for _ in range(40)]) + base = groups.baseline(boards) + table = groups.group_edge(boards, 2, base, min_boards=10) + assert table["A@2@3|B@2@3"]["edge"] > 0 + assert table["C@2@3|D@2@3"]["edge"] < 0 + assert table["A@2@3|B@2@3"]["boards"] == 40 + + +def test_item_count_is_part_of_the_key(): + """Derselbe Champion mit und ohne Items ist nicht dieselbe Einheit. + + Beide Boards tragen sechs Items — nur anders verteilt. Genau dafür ist + die Kontrolle da: die Frage ist, wer die Items tragen soll. + """ + boards = ([board(1, 9, ("A", 3), ("B", 3), ("C", 0)) for _ in range(40)] + + [board(8, 9, ("A", 0), ("B", 0), ("C", 3), ("D", 3)) for _ in range(40)]) + base = groups.baseline(boards) + table = groups.group_edge(boards, 2, base, min_boards=10) + assert "A@2@3|B@2@3" in table and "A@2@0|B@2@0" in table + assert table["A@2@3|B@2@3"]["edge"] > table["A@2@0|B@2@0"]["edge"] + + +def test_rare_groups_are_dropped(): + boards = [board(1, 9, ("A", 3), ("B", 3)) for _ in range(9)] + base = groups.baseline(boards) + assert groups.group_edge(boards, 2, base, min_boards=10) == {} + + +def test_groups_smaller_than_size_are_skipped(): + boards = [board(1, 9, ("A", 3)) for _ in range(40)] + base = groups.baseline(boards) + assert groups.group_edge(boards, 2, base, min_boards=10) == {} + + +def test_build_needs_enough_boards(): + import json + + class Conn: + """Minimaler sqlite-Ersatz: execute(...).fetchall().""" + + def __init__(self, n): + row = (1, 9, json.dumps([{"character_id": "A", "tier": 2, + "itemNames": []}])) + self.rows = [row] * n + + def execute(self, *_): + return self + + def fetchall(self): + return self.rows + + static = {"A": {"cost": 1, "traits": []}} + assert groups.build(Conn(10), 18, static) == {} + out = groups.build(Conn(groups.MIN_BOARDS * 20), 18, static) + assert out["boards"] == groups.MIN_BOARDS * 20 + + +def test_only_groups_with_exactly_one_carry(): + """Ein Champion trägt drei Items, der Rest stützt.""" + assert groups.one_carry("A@2@3|B@2@0") + assert groups.one_carry("A@2@3|B@1@1|C@3@2") + assert not groups.one_carry("A@2@3|B@2@3") # zwei Carries + assert not groups.one_carry("A@2@1|B@2@0") # gar keiner + + +def test_build_keeps_only_one_carry_groups(): + import json + + class Conn: + def __init__(self, rows): + self.rows = rows + + def execute(self, *_): + return self + + def fetchall(self): + return self.rows + + def raw(place, *pairs): + units = [{"character_id": api, "tier": 1, "itemNames": ["x"] * n} + for api, n in pairs] + return (place, 9, json.dumps(units)) + + static = {a: {"cost": 1, "traits": []} for a in "ABCD"} + rows = ([raw(1, ("A", 3), ("B", 0))] * (groups.MIN_BOARDS * 12) + + [raw(2, ("C", 3), ("D", 3))] * (groups.MIN_BOARDS * 12)) + out = groups.build(Conn(rows), 18, static) + assert "C@1@3|D@1@3" not in out["2"] + assert "A@1@3|B@1@0" in out["2"] + + +def carrier(place, *pairs): + """pairs: (api, sterne, [items]) — ein Board mit Trägern.""" + return {"place": place, "level": 9, "items": 3, "_edge": 4.5 - place, + "carried": [(api, st, min(len(items), 3), set(items)) + for api, st, items in pairs]} + + +def test_only_craftable_items_are_ranked(): + """Embleme, Artefakte und Radiant-Varianten kann man nicht planen.""" + boards = [carrier(1, ("T", 2, ["I_NORMAL", "I_EMBLEM"]))] * 200 + table = groups.unit_item_edge(boards, {"I_NORMAL"}, min_boards=10) + assert set(table) == {"T|I_NORMAL"} + + +def test_item_value_is_measured_against_the_same_champion(): + """Ein gutes Board macht nicht jedes Item darauf gut. + + Beide Items liegen auf derselben Unit, aber nur eines auf den guten + Boards. Das andere darf davon nicht profitieren. + """ + boards = ([carrier(1, ("T", 2, ["GOOD"])) for _ in range(60)] + + [carrier(7, ("T", 2, ["BAD"])) for _ in range(60)]) + t = groups.unit_item_edge(boards, {"GOOD", "BAD"}, min_boards=10) + assert t["T|GOOD"]["edge"] > 0 > t["T|BAD"]["edge"] + + +def test_stars_and_item_count_are_the_comparison_group(): + """Ein Item darf nicht gut aussehen, nur weil sein Träger der Carry ist. + + Der Champion steht mit drei Items auf guten Boards, mit einem auf + schlechten. Beide Items liegen nur auf je einer der beiden Gruppen — + ohne die Kontrolle gewänne allein das Item der Drei-Item-Boards. + """ + boards = ([carrier(2, ("T", 2, ["A", "X", "Y"])) for _ in range(80)] + + [carrier(7, ("T", 2, ["B"])) for _ in range(80)]) + t = groups.unit_item_edge(boards, {"A", "B"}, min_boards=10) + assert abs(t["T|A"]["edge"]) < 0.05 and abs(t["T|B"]["edge"]) < 0.05 + + +def test_items_are_kept_apart_per_champion(): + """Dieselbe Rolle, anderer Champion: die Werte dürfen nicht verschmelzen.""" + boards = ([carrier(1, ("M", 2, ["AD_ITEM"])) for _ in range(60)] + + [carrier(1, ("N", 2, ["AP_ITEM"])) for _ in range(60)]) + t = groups.unit_item_edge(boards, {"AD_ITEM", "AP_ITEM"}, min_boards=10) + assert set(t) == {"M|AD_ITEM", "N|AP_ITEM"} + + +def test_role_of_maps_riot_names(): + assert groups.role_of({"role": "MagicTank"}) == "Tank" + assert groups.role_of({"role": "AttackFighter"}) == "Bruiser" + assert groups.role_of({"role": "MagicCaster"}) == "Mage" + assert groups.role_of({"role": "AttackMarksman"}) == "Scharfschütze" + assert groups.role_of({"role": ""}) is None + + +def test_unit_edge_uses_one_star_level(): + """Ohne feste Sternstufe gewinnt die billige Unit, die nur als 3★ überlebt.""" + boards = ([{"place": 1, "level": 9, "items": 0, + "entities": [("CHEAP", 3, 0)]} for _ in range(60)] + + [{"place": 5, "level": 9, "items": 0, + "entities": [("CHEAP", 2, 0), ("RICH", 2, 0)]} for _ in range(60)]) + base = groups.baseline(boards) + two = groups.unit_edge(boards, base, min_boards=10, stars=2) + three = groups.unit_edge(boards, base, min_boards=10, stars=3) + # Auf 2★ zählen nur die schlechten Boards, auf 3★ nur die guten. + assert set(two) == {"CHEAP", "RICH"} + assert set(three) == {"CHEAP"} + assert three["CHEAP"]["edge"] > two["CHEAP"]["edge"] + + +def test_thiefs_gloves_carriers_are_excluded(): + """Die Handschuhe würfeln ihren Inhalt — das hat niemand so gebaut. + + Riot listet die gewürfelten Items daneben, als wären sie gelegt worden. + Hier stehen sie nur auf den guten Boards; ohne den Ausschluss würden + sie dadurch gut aussehen. + """ + boards = ([carrier(1, ("T", 2, ["DA_ThiefsGloves", "ZUFALL", "X"])) + for _ in range(200)] + + [carrier(6, ("T", 2, ["ECHT", "X", "Y"])) for _ in range(200)]) + t = groups.unit_item_edge(boards, {"ZUFALL", "ECHT", "X"}, min_boards=10) + assert "T|ZUFALL" not in t + assert "T|ECHT" in t diff --git a/backend/tests/test_hexgrid.py b/backend/tests/test_hexgrid.py new file mode 100644 index 0000000..3da7b35 --- /dev/null +++ b/backend/tests/test_hexgrid.py @@ -0,0 +1,46 @@ +"""Das Spielfeld: Entfernungen und Nachbarn.""" + +from tft.sim import hexgrid as H + + +def test_neighbours_are_all_one_step_away(): + """Wenn das nicht gilt, bewegen sich Units in Sprüngen.""" + for col in range(H.COLS): + for row in range(H.ROWS): + for n in H.neighbours(col, row): + assert H.distance((col, row), n) == 1 + + +def test_offset_rows_are_not_a_grid(): + """Versetzte Reihen: gleicher Spaltenabstand, andere Entfernung. + + Genau dafür ist die Umrechnung da — mit rohen Spalten wäre beides 1. + """ + assert H.distance((2, 0), (1, 1)) == 1 + assert H.distance((2, 0), (3, 1)) == 2 + + +def test_distance_along_a_row_counts_columns(): + assert H.distance((0, 0), (6, 0)) == 6 + assert H.distance((3, 4), (3, 4)) == 0 + + +def test_neighbour_count_is_six_in_the_middle(): + assert len(H.neighbours(3, 3)) == 6 + # Am Rand fallen Felder weg. + assert len(H.neighbours(0, 0)) < 6 + + +def test_neighbour_order_is_stable(): + """Sonst hängt der Ausgang eines Kampfes an der Iterationsreihenfolge.""" + assert H.neighbours(3, 3) == H.neighbours(3, 3) + + +def test_mirror_swaps_the_halves(): + assert H.mirror(0, 0) == (H.COLS - 1, H.ROWS - 1) + assert H.mirror(*H.mirror(2, 5)) == (2, 5) + + +def test_halves_are_split_evenly(): + assert H.own_half(0, "a") and not H.own_half(4, "a") + assert H.own_half(7, "b") and not H.own_half(3, "b") diff --git a/backend/tests/test_learn.py b/backend/tests/test_learn.py index d52f9ac..0e3e1db 100644 --- a/backend/tests/test_learn.py +++ b/backend/tests/test_learn.py @@ -1,6 +1,9 @@ import json -from tft.model.learn import learn +from tft.model.learn import MEAN_PLACEMENT, learn + +# Bester Fall: alle Boards auf Platz 1, also 3.5 Plätze über dem Schnitt. +MEAN_PLACEMENT_DELTA = MEAN_PLACEMENT - 1 def board_row(placement, unit="TFT17_A", item="TFT_Item_X", trait_tier=1): @@ -18,9 +21,19 @@ def test_strong_item_gets_multiplier_above_1(): def test_shrinkage_with_thin_data(): + """Drei Boards dürfen den Multiplikator kaum bewegen. + + Der Vergleich läuft gegen DELTA_SCALE, nicht gegen eine feste Zahl: + 3 gegen 30 Pseudo-Boards lassen höchstens ein Zehntel des vollen + Effekts durch, egal wie stark die Skala eingestellt ist. + """ + from tft.model.learn import DELTA_SCALE + rows = [board_row(1, item="TFT_Item_Rare") for _ in range(3)] - learned = learn(rows) - assert abs(learned["items"]["TFT_Item_Rare"] - 1.0) < 0.06 + thin = learn(rows)["items"]["TFT_Item_Rare"] + full = learn([board_row(1, item="TFT_Item_Rare") for _ in range(2000)]) + assert abs(thin - 1.0) < 0.1 * MEAN_PLACEMENT_DELTA * DELTA_SCALE + assert full["items"]["TFT_Item_Rare"] > thin def test_trait_keys_match_score_format(): @@ -29,20 +42,38 @@ def test_trait_keys_match_score_format(): assert "TFT17_T@3" in learned["traits"] -def test_pair_lift_for_cooccurring_units(): - def pair_row(placement): - units = [ - {"character_id": "TFT17_A", "tier": 2, "itemNames": []}, - {"character_id": "TFT17_B", "tier": 2, "itemNames": []}, - ] +def test_pair_surplus_finds_real_synergy(): + """Zwei Units, die einzeln mittelmäßig sind, aber zusammen gewinnen.""" + def row(placement, *ids): + units = [{"character_id": i, "tier": 1, "itemNames": []} for i in ids] return (placement, json.dumps(units), json.dumps([]), json.dumps([])) - # A und B stehen immer zusammen in Top-4-Boards; C ist überall. - rows = [pair_row(1) for _ in range(100)] - solo = [{"character_id": "TFT17_C", "tier": 1, "itemNames": []}] - rows += [(1, json.dumps(solo), json.dumps([]), json.dumps([])) for _ in range(100)] - learned = learn(rows) - assert learned["pairs"]["TFT17_A|TFT17_B"] > 0.5 + rows = [] + # A und B einzeln mit Füllern: Mittelfeld. + rows += [row(4, "A", "X") for _ in range(120)] + rows += [row(5, "A", "Y") for _ in range(120)] + rows += [row(4, "B", "X") for _ in range(120)] + rows += [row(5, "B", "Y") for _ in range(120)] + # Zusammen: Platz 1. + rows += [row(1, "A", "B") for _ in range(120)] + pairs = learn(rows)["pairs"] + assert pairs["A|B"]["surplus"] > pairs["A|X"]["surplus"] + assert pairs["A|B"]["surplus"] > 0 + + +def test_pair_surplus_cannot_separate_units_that_never_split(): + """Bekannte Grenze: stehen zwei Units immer zusammen, zählt der + Einzelwert beide Male dieselbe Stärke — der Überschuss kippt ins Minus. + + In echten Daten fällt das kaum ins Gewicht: nur 9 von 1784 Paaren + hängen zu über 50 % aneinander, der Median liegt bei 2 %. + """ + def row(placement): + units = [{"character_id": i, "tier": 1, "itemNames": []} for i in ("A", "B")] + return (placement, json.dumps(units), json.dumps([]), json.dumps([])) + + pairs = learn([row(1) for _ in range(120)])["pairs"] + assert pairs["A|B"]["surplus"] < 0 def test_empty_rows(): @@ -86,3 +117,62 @@ def test_augment_spec_builder(): junk = build_spec({"desc": "", "effects": {}}, units) assert junk["offerable"] is False + + +def test_conditional_clause_does_not_leak_into_atoms(): + """Nur der unbedingte Satz zählt — effects["Gold"] ist der bedingte Nachschlag.""" + from tft.model.augments import build_spec + + units = {} + survivor = build_spec( + {"desc": "Gain 4 gold. After 3 players are eliminated, gain 100 more gold.", + "effects": {"Gold": 100.0, "InitialGold": 4.0, "PlayersDead": 3.0}}, units) + assert survivor["atoms"] == [{"kind": "gold_now", "amount": 4}] + + avenge = build_spec( + {"desc": "After 3 allies die, your team gains 28% Attack Damage.", + "effects": {"AD": 28.0}}, units) + assert avenge["atoms"] == [] + + # Wiederkehrend, aber unbedingt: bleibt erhalten. + each_round = build_spec( + {"desc": "Your team gains 2% Attack Damage per level. " + "Gain 2 XP each round for the rest of the game.", "effects": {}}, units) + assert each_round["atoms"] == [{"kind": "xp_per_round", "amount": 2}] + + +def test_pair_surplus_isolates_the_synergy(): + """Ein Paar zählt nur, was es über seine zwei Units hinaus bringt. + + A ist stark, B schwach. Zusammen laufen sie genau so, wie die Summe + erwarten lässt — also kein Überschuss. + """ + from tft.model.learn import pair_surplus, value + + a_only = [1] * 100 + b_only = [8] * 100 + together = [1, 8] * 50 + units = {"A": value(a_only + together[::2]), "B": value(b_only + together[1::2])} + pairs = pair_surplus({("A", "B"): together}, units) + assert abs(pairs["A|B"]["surplus"]) < 0.5 + assert pairs["A|B"]["boards"] == 100 + + +def test_pair_surplus_needs_enough_boards(): + from tft.model.learn import PAIR_MIN_N, pair_surplus, value + + units = {"A": 0.0, "B": 0.0} + thin = pair_surplus({("A", "B"): [1] * (PAIR_MIN_N - 1)}, units) + thick = pair_surplus({("A", "B"): [1] * PAIR_MIN_N}, units) + assert thin == {} + assert "A|B" in thick + # Unbekannte Units fliegen raus statt still als 0 zu zählen. + assert pair_surplus({("A", "X"): [1] * PAIR_MIN_N}, units) == {} + + +def test_value_shrinks_towards_average(): + from tft.model.learn import value + + assert value([1] * 3) < value([1] * 300) # wenige Boards -> naher an 0 + assert value([8] * 300) < 0 < value([1] * 300) + assert abs(value([])) < 1e-9 diff --git a/backend/tests/test_metatft.py b/backend/tests/test_metatft.py new file mode 100644 index 0000000..81cb5ad --- /dev/null +++ b/backend/tests/test_metatft.py @@ -0,0 +1,203 @@ +"""Der MetaTFT-Parser muss dieselben Tabellen liefern wie der cdragon-Parser. + +Die Fixtures sind gekürzte, aber echte Ausschnitte aus +data.metatft.com/lookups/TFTSet18_latest_en_us.json. +""" + +import pytest + +from tft.staticdata import metatft + + +@pytest.fixture +def raw(): + return { + "_metadata": {"set": "TFTSet18", "setName": "Enchanted Wilds", "patch": "pbe"}, + "roles": {"DA_Role_AttackCaster": "Attack Caster"}, + "units": [ + { + "apiName": "TFT18_Ashe", "characterName": "TFT18_Ashe", "name": "Ashe", + "cost": 5, "shopUnit": True, "role": "DA_Role_AttackCaster", + "traits": ["Blossom", "Hunter"], + "traitApiNames": ["DA_18_Blossom", "DA_18_Hunter"], + "stats": {"hp": 900, "damage": 75, "armor": 45, "magicResist": 45, + "attackSpeed": 0.8, "mana": 80, "initialMana": 20, + "critChance": 0.25, "critMultiplier": 1.4, "range": 5}, + "curveValues": {"AbilityShield": [[1, 60], [2, 90], [3, 200], [4, 310]], + "ChillPercent": [[1, 20], [4, 20]]}, + "ability": { + "name": "Spirit Rift", + # Wie im Original: erst ein Mana-Tag, dann zwei Schadenszahlen. + "desc": ('Costs mana. Deals' + ' then' + ' physical damage,' + ' shields for , slowing by' + ' %.'), + "variables": [], + "attributeValues": { + "TFTCalculationAttributes.ManaCalc1": [3, 3, 100, 100], + "TFTCalculationAttributes.PhysicalDamageCalc1": [440, 660, 1000, 1000], + "TFTCalculationAttributes.PhysicalDamageCalc2": [7, 11, 220, 220], + }, + }, + }, + { # Verwandlungsform: kein shopUnit, gehört nicht in den Pool. + "apiName": "TFT18_EliseSpider", "name": "Elise", "cost": 2, + "shopUnit": False, "traits": ["Coven"], "traitApiNames": ["DA_18_Coven"], + "stats": {"hp": 700}, "ability": {}, + }, + { # Lücke im PBE-Dump: ohne HP wäre die eHP 0. + "apiName": "TFT18_Ivern", "name": "Ivern", "cost": 5, "shopUnit": True, + "traits": ["Greenfather"], "traitApiNames": ["DA_18_Greenfather"], + "stats": {"damage": 60, "armor": 55}, "ability": {}, + }, + ], + "traits": [ + {"apiName": "DA_18_Brawler", "name": "Brawler", "type": "class", + "icon": "T_TraitIcon_18_Brawler", + "effects": [ + {"minUnits": 2, "style": 1, + "desc": '(2) '}, + {"minUnits": 4, "style": 3, + "desc": '(4) '}, + ], + "curveValues": {"BrawlerHealthPercent": [[0, 0], [1, 0.25], [2, 0.4]]}}, + {"apiName": "DA_18_Blossom", "name": "Blossom", "type": "origin", + "icon": "T_TraitIcon_18_Blossom", "effects": [], "curveValues": {}}, + {"apiName": "DA_18_Hunter", "name": "Hunter", "type": "class", + "icon": "T_TraitIcon_18_Hunter", "effects": [], "curveValues": {}}, + ], + "items": [ + {"apiName": "DA_Component_RecurveBow", "name": "Recurve Bow", "composition": [], + "icon": "T_RecurveBow", + "statLine": '', + "curveValues": {"AS": [[1, 1.1]]}}, + {"apiName": "DA_Component_NeedlesslyLargeRod", "name": "Rod", "composition": [], + "icon": "T_Rod", + "statLine": '', + "curveValues": {"AP": [[1, 0.1]]}}, + {"apiName": "DA_Component_GiantsBelt", "name": "Belt", "composition": [], + "icon": "T_Belt", + "statLine": '', + "curveValues": {"Health": [[1, 150]]}}, + ], + "augments": [ + {"apiName": "DA_AdvancedLoan", "name": "Advanced Loan", "rarity": "Gold", + "icon": "T_AugmentIcon_AdvancedLoan", + "desc": 'Gain Gold.', + "curveValues": {"GoldInstantBasic": [[1, 20]]}}, + ], + } + + +def test_meta_and_unit_filter(raw): + tables = metatft.parse(raw) + assert tables["meta"]["set"] == 18 + assert tables["meta"]["set_name"] == "Enchanted Wilds" + # EliseSpider ist kein Shop-Unit, Ivern hat keine HP. + assert list(tables["units"]) == ["TFT18_Ashe"] + assert tables["meta"]["incomplete_units"] == ["Ivern (hp)"] + + +def test_missing_attack_damage_is_reported_but_kept(raw): + """Ohne AD ist der Auto-DPS 0 — die Unit wird trotzdem gespielt. + + Anders als bei fehlender HP fliegt sie deshalb nicht aus dem Pool. + """ + del raw["units"][0]["stats"]["damage"] + tables = metatft.parse(raw) + assert "TFT18_Ashe" in tables["units"] + assert "Ashe (damage)" in tables["meta"]["incomplete_units"] + + +def test_unit_matches_cdragon_shape(raw): + unit = metatft.parse(raw)["units"]["TFT18_Ashe"] + assert unit["traits"] == ["DA_18_Blossom", "DA_18_Hunter"] + assert unit["role"] == "AttackCaster" + assert unit["stats"]["hp"] == 900 + # Der erste Tag im Text ist die Manazahl 3 — der Schaden ist 440. + # cdragon zählt Sterne ab Index 1; MetaTFT ab 0. + assert unit["spell_damage"] == [0, 440, 660, 1000] + assert unit["spell_damage_type"] == "physical" + assert unit["spell_scaling"] == "ad" + # Kurven sind Treppen: Stufe 4 fehlt, Stufe 1 gilt weiter. + assert unit["ability_vars"]["AbilityShield"] == [0, 60, 90, 200] + assert unit["ability_vars"]["ChillPercent"] == [0, 20, 20, 20] + + +def test_trait_breakpoints_carry_their_curve_values(raw): + brawler = metatft.parse(raw)["traits"]["DA_18_Brawler"] + assert [b["min_units"] for b in brawler["breakpoints"]] == [2, 4] + assert brawler["breakpoints"][0]["variables"] == {"BrawlerHealthPercent": 0.25} + assert brawler["breakpoints"][1]["variables"] == {"BrawlerHealthPercent": 0.4} + + +def test_unknown_traits_are_dropped(raw): + # Greenfather steht in keiner Trait-Tabelle; ein Verweis darauf würde + # active_trait_tiers durchlaufen und im UI als Geist-Trait auftauchen. + raw["units"][0]["traitApiNames"].append("DA_18_DoesNotExist") + assert metatft.parse(raw)["units"]["TFT18_Ashe"]["traits"] == [ + "DA_18_Blossom", "DA_18_Hunter"] + + +def test_item_effects_follow_statsheet_conventions(raw): + items = metatft.parse(raw)["items"] + # AS und AP sind Prozentzahlen, Health ist flach — wie in Set 17. + assert items["DA_Component_RecurveBow"]["effects"] == {"AS": pytest.approx(10.0)} + assert items["DA_Component_NeedlesslyLargeRod"]["effects"] == {"AP": pytest.approx(10.0)} + assert items["DA_Component_GiantsBelt"]["effects"] == {"Health": 150} + + +def test_augment_tier_and_rendered_desc(raw): + aug = metatft.parse(raw)["augments"]["DA_AdvancedLoan"] + assert aug["tier"] == 2 # Gold + assert aug["desc"] == "Gain 20 Gold." + assert aug["effects"] == {"GoldInstantBasic": 20} + + +def test_curve_steps_hold_the_last_value(): + curve = [[1, 5], [3, 9]] + assert [metatft.curve_at(curve, i) for i in (1, 2, 3, 4)] == [5, 5, 9, 9] + assert metatft.curve_at([], 1) is None + + +@pytest.mark.parametrize("value,fmt,want", [ + (0.25, "percent", 0.25), + (1.10, "percentMinusOne", pytest.approx(0.10)), + # Durability wird als Resthärte angegeben: 0.96 = 4% weniger Schaden. + (0.96, "invertedPercent", pytest.approx(0.04)), + (175, None, 175), +]) +def test_format_drives_the_conversion(value, fmt, want): + assert metatft.as_fraction(value, fmt) == want + + +def test_spell_picks_the_biggest_damage_not_the_first_tag(raw): + """Lux nennt im Tooltip zuerst 3 Mana, dann 330 Schaden. + + Wer den ersten nimmt, modelliert die Manazahl als + Schaden — das betraf 33 von 63 Units. + """ + unit = metatft.parse(raw)["units"]["TFT18_Ashe"] + assert unit["spell_damage"][1] == 440 + assert unit["spell_scaling"] == "ad" + assert unit["spell_damage_type"] == "physical" + + +def test_on_attack_damage_is_flagged(raw): + ability = raw["units"][0]["ability"] + ability["desc"] = ('Attacks deal bonus damage.') + assert metatft.parse(raw)["units"]["TFT18_Ashe"]["spell_on_attack"] is True + + +def test_asset_names_are_kept_for_the_match_api(raw): + # Riot nennt Units beim Asset-Namen; ohne die Liste findet learn.py nichts. + raw["units"][0]["assetNames"] = ["DA_18_Ashe"] + assert metatft.parse(raw)["units"]["TFT18_Ashe"]["asset_names"] == ["DA_18_Ashe"] diff --git a/backend/tests/test_pairs.py b/backend/tests/test_pairs.py new file mode 100644 index 0000000..46cede0 --- /dev/null +++ b/backend/tests/test_pairs.py @@ -0,0 +1,113 @@ +"""Gruppen: Bindung aus den Pro-Comps, Stärke aus den Boards.""" + +from tft.model import pairs +from tft.staticdata import procomps + +UNITS = {a: {"cost": 1, "traits": ["T"], "asset_names": [f"DA_{a}"]} + for a in "ABCDEF"} + + +def board(place, *units): + return {"place": place, "cell": (9, 3), + "entities": [(u, 2, 0) for u in units]} + + +def cell(b): + return b["cell"] + + +def test_bond_asks_about_the_rarer_unit(): + """Nicht "wie oft zusammen", sondern "wie oft zusammen, wenn möglich". + + B steht nie ohne A. C steht halb so oft bei A. Rohe Zählung sähe C + vorne, weil C öfter vorkommt. + """ + groups = [["A", "B", "X", "Y"]] * 3 + [["A", "C", "X", "Y"]] * 2 \ + + [["C", "D", "X", "Y"]] * 2 + w = pairs.weights(groups, pseudo=0) + assert w[("A", "B")] == 1.0 + assert w[("A", "C")] == 0.5 + + +def test_pseudo_counts_hold_back_single_sightings(): + """Ein Paar mit einer Nennung darf nicht wie eine Regel aussehen.""" + groups = [["A", "B", "C", "D"]] + assert pairs.weights(groups, pseudo=0)[("A", "B")] == 1.0 + assert pairs.weights(groups, pseudo=3)[("A", "B")] == 0.25 + + +def test_every_pair_in_a_group_must_clear_the_threshold(): + """Ein Dritter darf nicht an nur einem der beiden hängen. + + C bindet stark an A, aber gar nicht an B. Also kein Trio. + """ + bond = {("A", "B"): 0.9, ("A", "C"): 0.9, ("B", "C"): 0.1} + out = pairs.candidates(bond, UNITS, min_bond=0.4) + assert ("A", "B") in out and ("A", "C") in out + assert ("A", "B", "C") not in out + + +def test_a_group_needs_a_shared_trait(): + """Ohne das kommt Unsinn heraus: eine Comp hat acht bis neun Units, + zwei davon stehen nebeneinander, ohne etwas miteinander zu tun. + """ + units = {"A": {"traits": ["T"]}, "B": {"traits": ["T"]}, + "C": {"traits": ["U"]}} + bond = {("A", "B"): 0.9, ("A", "C"): 0.9, ("B", "C"): 0.9} + out = pairs.candidates(bond, units, min_bond=0.4) + assert ("A", "B") in out + assert ("A", "C") not in out and ("A", "B", "C") not in out + + +def test_strength_is_measured_against_comparable_boards(): + boards = [board(1, "A", "B", "C") for _ in range(400)] \ + + [board(7, "D", "E", "F") for _ in range(400)] + out = pairs.strength(boards, {(9, 3): 4.0}, cell, + [("A", "B"), ("D", "E")], min_boards=10) + assert out[("A", "B")]["edge"] > 0 > out[("D", "E")]["edge"] + assert out[("A", "B")]["boards"] == 400 + + +def test_rare_groups_are_dropped(): + boards = [board(1, "A", "B") for _ in range(5)] + assert pairs.strength(boards, {(9, 3): 4.0}, cell, [("A", "B")], + min_boards=10) == {} + + +def test_select_drops_overlapping_and_negative_groups(): + """Sonst steht dieselbe Gruppe zwanzigmal leicht abgewandelt da.""" + scored = {("A", "B", "C"): {"edge": 0.5}, ("A", "B"): {"edge": 0.4}, + ("D", "E"): {"edge": 0.2}, ("A", "F"): {"edge": -0.1}} + assert pairs.select(scored) == [("A", "B", "C"), ("D", "E")] + + +def test_build_is_empty_without_input(): + assert pairs.build([], [board(1, "A", "B")], {}, cell, UNITS) == {} + assert pairs.build([["A", "B", "C", "D"]], [], {}, cell, UNITS) == {} + + +def test_comps_reads_every_stage(): + """Level 4 bis 9 sind eigene Stufen — jede zählt als Beobachtung.""" + doc = { + "content": {"comps": [{"data": {"$ref": "#/comp/x"}, "tier": "S"}]}, + "refs": [{ + "content_id": "x", + "metadata": {"title": "Test"}, + "content": { + "unit_positions": {str(i): {"apiName": f"DA_{a}"} + for i, a in enumerate("ABCD")}, + "variations": [ + {"type": "comp", + "unit_positions": {str(i): {"apiName": f"DA_{a}"} + for i, a in enumerate("ABCDE")}}, + {"type": "tierlist"}, + # zu klein, fliegt raus + {"type": "comp", + "unit_positions": {"0": {"apiName": "DA_A"}}}, + ], + }, + }], + } + out = procomps.comps(doc, UNITS) + assert [len(c["units"]) for c in out] == [4, 5] + assert all(c["tier"] == "S" for c in out) diff --git a/backend/tests/test_parse.py b/backend/tests/test_parse.py index c715742..e7d81ac 100644 --- a/backend/tests/test_parse.py +++ b/backend/tests/test_parse.py @@ -13,7 +13,8 @@ RAW = { "role": "Caster", "stats": {"hp": 600, "armor": 25, "magicResist": 25, "damage": 40, "attackSpeed": 0.7, "mana": 60, "initialMana": 10}, - "squareIcon": "ASSETS/x.TFT_Set17.tex", + "squareIcon": "ASSETS/x_splash_tile.TFT_Set17.tex", + "tileIcon": "ASSETS/HUD/x_Square.TFT_Set17.tex", "ability": { "name": "Blast", "desc": "Deal @ModifiedDamage@ (%i:scaleAP%) magic damage.", @@ -92,6 +93,23 @@ def test_resolve_spell_adaptive_and_fallback(): assert resolve_spell(utility)["spell_damage"] is None +def test_icon_url_drops_set_suffix(): + """cdragon serviert ohne Set-Suffix — mit Suffix gab es stumme 404er.""" + from tft.staticdata.parse import icon_url + + assert icon_url("ASSETS/Characters/TFT17_Illaoi/HUD/TFT17_Illaoi_Square.TFT_Set17.tex") == ( + "https://raw.communitydragon.org/latest/game" + "/assets/characters/tft17_illaoi/hud/tft17_illaoi_square.png" + ) + assert icon_url("ASSETS/UX/TraitIcons/Trait_Icon_17_Doomer.TFT_Set17.tex").endswith( + "/assets/ux/traiticons/trait_icon_17_doomer.png" + ) + # Ohne Suffix bleibt der Name unverändert. + assert icon_url("ASSETS/Maps/TFT/Icons/Items/TFT_Item_BFSword.tex").endswith( + "/tft_item_bfsword.png" + ) + + def test_augment_tier_from_icon(): from tft.staticdata.parse import augment_tier assert augment_tier("ASSETS/x/GodAugmentEkko_II.TFT_Set17.tex") == 2 diff --git a/backend/tests/test_rating.py b/backend/tests/test_rating.py new file mode 100644 index 0000000..b29e231 --- /dev/null +++ b/backend/tests/test_rating.py @@ -0,0 +1,79 @@ +"""Boardbewertung: Merkmale, Baumdurchlauf, Grenzfälle.""" + +import pytest + +from tft.model import rating + +STATIC = { + "units": { + "U_TANK": {"cost": 1, "traits": ["T_BRAWL"], "name": "Tank"}, + "U_CARRY": {"cost": 4, "traits": ["T_BRAWL", "T_SHOT"], "name": "Carry"}, + "U_FIVE": {"cost": 5, "traits": ["T_SHOT"], "name": "Five"}, + }, + "traits": { + "T_BRAWL": {"breakpoints": [{"min_units": 2}, {"min_units": 4}]}, + "T_SHOT": {"breakpoints": [{"min_units": 2}]}, + }, +} + + +def board(*specs): + return [{"api_name": a, "stars": s, "items": list(i)} for a, s, i in specs] + + +@pytest.fixture +def art(): + return {"static": STATIC, "component_pool": ["C_SWORD"], "item_pool": ["I_BLADE"], + "rating": {"tables": {}}} + + +def test_trait_counts_and_tiers(): + b = board(("U_TANK", 1, []), ("U_CARRY", 1, []), ("U_FIVE", 1, [])) + counts = rating.trait_counts(b, STATIC["units"]) + assert counts == {"T_BRAWL": 2, "T_SHOT": 2} + assert rating.trait_tiers(counts, STATIC["traits"]) == {"T_BRAWL": 1, "T_SHOT": 1} + # Eine Unit unter dem Breakpoint aktiviert nichts. + single = rating.trait_counts(board(("U_TANK", 1, [])), STATIC["units"]) + assert rating.trait_tiers(single, STATIC["traits"]) == {} + + +def test_features_are_stable_in_length(art): + empty = rating.features([], 1, art) + full = rating.features(board(("U_CARRY", 3, ["I_BLADE", "C_SWORD"])), 9, art) + assert len(empty) == len(full) + assert all(isinstance(x, (int, float)) for x in empty + full) + + +def test_unknown_units_are_ignored(art): + known = rating.features(board(("U_TANK", 1, [])), 5, art) + mixed = rating.features(board(("U_TANK", 1, []), ("NOPE", 3, [])), 5, art) + assert known == mixed + + +def test_no_model_means_no_prediction(art): + assert rating.predict_placement(board(("U_TANK", 1, [])), 5, art) is None + assert rating.board_rating(board(("U_TANK", 1, [])), 5, art) is None + + +def test_tree_walk_and_clamping(art): + # Ein Baum, der nur auf die Unit-Zahl schaut (Merkmal 4). + art["rating"] = {"tables": {}, "base": 4.0, "lr": 1.0, + "trees": [[4, 1.5, 2.0, -2.0]]} + few = rating.predict_placement(board(("U_TANK", 1, [])), 3, art) + many = rating.predict_placement( + board(("U_TANK", 1, []), ("U_CARRY", 1, []), ("U_FIVE", 1, [])), 9, art) + assert few == 6.0 and many == 2.0 + assert rating.board_rating(board(("U_TANK", 1, [])), 3, art) == pytest.approx(-1.5) + + # Ausreißer werden auf 1 bis 8 gestutzt — es gibt keinen Platz 0. + art["rating"]["trees"] = [[4, 1.5, 99.0, -99.0]] + assert rating.predict_placement(board(("U_TANK", 1, [])), 3, art) == 8.0 + assert rating.predict_placement( + board(("U_TANK", 1, []), ("U_CARRY", 1, [])), 9, art) == 1.0 + + +def test_learned_tables_reach_the_features(art): + art["rating"]["tables"] = {"unit": {"U_CARRY": 1.5}} + plain = rating.features(board(("U_TANK", 1, [])), 5, art) + good = rating.features(board(("U_CARRY", 1, [])), 5, art) + assert good != plain diff --git a/backend/tests/test_score.py b/backend/tests/test_score.py deleted file mode 100644 index 45a6abf..0000000 --- a/backend/tests/test_score.py +++ /dev/null @@ -1,146 +0,0 @@ -import pytest - -from tft.model.baseline import classify_role, unit_value -from tft.model.calibrate import spearman -from tft.model.score import active_trait_tiers, board_profiles, score_board - -STATIC = { - "units": { - "TFT17_A": { - "cost": 1, - "traits": ["TFT17_Tank"], - "role": "Tank", - "stats": {"hp": 650, "armor": 40, "magicResist": 40, "damage": 50, - "attackSpeed": 0.6, "mana": 60, "initialMana": 0, "range": 1}, - }, - "TFT17_B": { - "cost": 4, - "traits": ["TFT17_Tank"], - "role": "Marksman", - "stats": {"hp": 700, "armor": 25, "magicResist": 25, "damage": 75, - "attackSpeed": 0.75, "mana": 100, "initialMana": 20, "range": 4}, - }, - }, - "traits": { - "TFT17_Tank": { - "breakpoints": [{"min_units": 2, "style": 1}, {"min_units": 4, "style": 3}], - }, - }, -} - -ARTIFACT = { - "meta": {"set": 17}, - "static": { - **STATIC, - "items": {"TFT_Item_InfinityEdge": {"effects": {"AD": 0.35, "CritChance": 35.0}}}, - "augments": {}, - }, - "roles": {}, -} - - -def test_unit_value(): - assert unit_value(1, 1) == 1 - assert unit_value(4, 2) == 12 - assert unit_value(3, 3) == 27 - - -def test_classify_role_from_cdragon_role(): - assert classify_role(STATIC["units"]["TFT17_A"]) == "frontline" - assert classify_role(STATIC["units"]["TFT17_B"]) == "ad_carry" - - -def test_trait_activation(): - board = [{"api_name": "TFT17_A", "stars": 1, "items": []}, - {"api_name": "TFT17_B", "stars": 1, "items": []}] - tiers = active_trait_tiers(board, STATIC["traits"], STATIC["units"]) - assert tiers == {"TFT17_Tank": 1} - assert active_trait_tiers(board[:1], STATIC["traits"], STATIC["units"]) == {} - - -def test_score_ordering(): - weak = [{"api_name": "TFT17_A", "stars": 1, "items": []}] - strong = [{"api_name": "TFT17_B", "stars": 2, "items": []}] - assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT) - - -def test_items_increase_score(): - bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}] - with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}] - 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(): - 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], [10, 10, 10, 10]) == 0.0 - - -def test_craftable_item_pool(): - from tft.model.artifact import craftable_items - items = { - "TFT_Item_InfinityEdge": {"composition": ["TFT_Item_BFSword", "TFT_Item_SparringGloves"]}, - "TFT_Item_BFSword": {"composition": []}, - "TFT_Item_Emblem": {"composition": ["TFT_Item_Spatula", "TFT_Item_BFSword"]}, - "TFT17_Item_Weird": {"composition": ["TFT_Item_BFSword", "TFT_Item_BFSword"]}, - "TFT_Item_Radiant": {"composition": None}, - } - assert craftable_items(items) == ["TFT_Item_InfinityEdge"] - - -def _built_artifact(): - from tft.model import artifact as artifact_mod - - static = { - "meta": {"set": 17, "patch": "test"}, - "units": { - "TANK": {"cost": 2, "traits": ["T_AD"], "role": "Tank", - "stats": {"hp": 900, "armor": 50, "magicResist": 50, "damage": 45, - "attackSpeed": 0.6, "mana": 60, "initialMana": 0}, - "spell_damage": None, "spell_scaling": None}, - "CARRY": {"cost": 2, "traits": ["T_AD"], "role": "Marksman", - "stats": {"hp": 600, "armor": 20, "magicResist": 20, "damage": 70, - "attackSpeed": 0.8, "mana": 50, "initialMana": 0}, - "spell_damage": [0, 250, 375, 560, 0, 0, 0], "spell_scaling": "ad"}, - }, - "traits": {"T_AD": {"breakpoints": [ - {"min_units": 2, "style": 1, "variables": {"BonusAP": 20.0}}]}}, - "items": {"SWORD": {"effects": {"AD": 0.35}}}, - "augments": {}, - } - return artifact_mod.build(static) - - -def test_balanced_beats_lopsided(): - art = _built_artifact() - balanced = [{"api_name": "TANK", "stars": 1, "items": []}, - {"api_name": "CARRY", "stars": 1, "items": []}] - tanks = [{"api_name": "TANK", "stars": 1, "items": []} for _ in range(2)] - assert score_board(balanced, [], art) > score_board(tanks, [], art) - - -def test_items_and_stars_raise_strength(): - art = _built_artifact() - base = [{"api_name": "CARRY", "stars": 1, "items": []}] - starred = [{"api_name": "CARRY", "stars": 2, "items": []}] - equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}] - assert score_board(starred, [], art) > score_board(base, [], art) - assert score_board(equipped, [], art) > score_board(base, [], art) diff --git a/backend/tests/test_statsheet.py b/backend/tests/test_statsheet.py deleted file mode 100644 index b785c38..0000000 --- a/backend/tests/test_statsheet.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest - -from tft.model import statsheet - - -def unit(spell=None, scaling="ap", **overrides): - stats = {"hp": 700, "armor": 30, "magicResist": 30, "damage": 50, - "attackSpeed": 0.7, "mana": 60, "initialMana": 10, - "critChance": 0.25, "critMultiplier": 1.4} - stats.update(overrides) - return {"stats": stats, "spell_damage": spell, "spell_scaling": scaling} - - -ITEMS = { - "sword": {"effects": {"AD": 0.35, "CritChance": 35.0}}, - "vest": {"effects": {"Armor": 20.0}}, - "rod": {"effects": {"AP": 10.0}}, - "unmodeled": {"effects": {"BurnPercent": 1.0}}, -} - - -def test_star_scaling(): - one = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None) - two = statsheet.unit_stats(unit(), 2, [], ITEMS, None, None) - assert two["ehp"] == pytest.approx(one["ehp"] * 1.8) - assert two["dps"] == pytest.approx(one["dps"] * 1.5) - - -def test_spell_damage_uses_star_index_and_ap(): - spell = [0, 200, 300, 450, 0, 0, 0] - bare = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None) - with_rod = statsheet.unit_stats(unit(spell=spell), 1, ["rod"], ITEMS, None, None) - no_spell = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None) - assert bare["dps"] > no_spell["dps"] - assert with_rod["dps"] > bare["dps"] # +10 AP skaliert den Spell - - -def test_items_change_profile(): - base = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None) - sword = statsheet.unit_stats(unit(), 1, ["sword"], ITEMS, None, None) - vest = statsheet.unit_stats(unit(), 1, ["vest"], ITEMS, None, None) - assert sword["dps"] > base["dps"] - assert vest["ehp"] > base["ehp"] - assert statsheet.item_is_modeled(ITEMS["sword"]) - assert not statsheet.item_is_modeled(ITEMS["unmodeled"]) - - -def test_on_attack_spell_scales_with_attack_speed(): - # Pro-Attacke-Passive: DPS = Schaden × AS, unabhängig vom Mana-Zyklus. - spell = [0, 50, 75, 110, 0, 0, 0] - u = unit(spell=spell) - u["spell_on_attack"] = True - on_attack = statsheet.unit_stats(u, 1, [], ITEMS, None, None) - assert on_attack["spell_dps"] == pytest.approx(50 * 0.7) - cast = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None) - assert on_attack["spell_dps"] > cast["spell_dps"] - - -def test_frontline_casts_more(): - spell = [0, 300, 450, 700, 0, 0, 0] - tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline") - carry = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "ad_carry") - assert tank["dps"] > carry["dps"] - - -def test_trait_buffs_keyword_mapping(): - traits = { - "T_AD": {"breakpoints": [{"min_units": 2, "style": 1, - "variables": {"BonusAD": 0.15}}]}, - "T_Mech": {"breakpoints": [{"min_units": 2, "style": 1, - "variables": {"Wolf_Gold": 1.0}}]}, - "T_Skip": {"breakpoints": [{"min_units": 2, "style": 1, - "variables": {"StunDuration": 1.5}}]}, - } - buffs, recognized = statsheet.trait_buffs( - {"T_AD": 1, "T_Mech": 1, "T_Skip": 1}, traits - ) - assert recognized == {"T_AD"} - assert buffs["ad_pct"] == pytest.approx(0.15) - - -def test_fraction_normalization(): - assert statsheet._fraction(0.15) == 0.15 - assert statsheet._fraction(15.0) == 0.15 diff --git a/backend/tests/test_tierlist.py b/backend/tests/test_tierlist.py new file mode 100644 index 0000000..1d75330 --- /dev/null +++ b/backend/tests/test_tierlist.py @@ -0,0 +1,42 @@ +"""Tierlist-Parser: Rauschzeilen raus, Meta-Stärke auf 1.0 zentriert.""" + +from tft.staticdata import tierlist + +PAGE = """ +
Unit
Tier
Avg Place
+
Lux (Blossom)S1.20 + 80.0%50.0%
+
LuxS2.62 + 33.6%134,21929.3%
+
ShenB4.59 + 10.0%139,56330.4%
+
EliseB4.55 + 9.1%110.0%
+
EliseB4.46 + 10.3%67,54114.7%
+""" + + +def test_small_samples_and_variants_are_dropped(): + rows = tierlist.parse(PAGE) + # "Lux (Blossom)" mit 5 Spielen ist Rauschen, die Elise-Zeile mit 11 auch. + assert sorted(rows) == ["Elise", "Lux", "Shen"] + assert rows["Lux"]["avg_place"] == 2.62 + assert rows["Lux"]["games"] == 134219 + assert rows["Elise"]["avg_place"] == 4.46 # die größere Stichprobe gewinnt + + +def test_meta_strength_centres_on_one(): + rows = tierlist.parse(PAGE) + units = {"TFT18_Lux": {"name": "Lux"}, "TFT18_Shen": {"name": "Shen"}, + "TFT18_Elise": {"name": "Elise"}, "TFT18_Ivern": {"name": "Ivern"}} + meta = tierlist.meta_strength(rows, units) + # Ivern steht in keiner Zeile und bekommt keinen Wert. + assert set(meta) == {"TFT18_Lux", "TFT18_Shen", "TFT18_Elise"} + assert abs(sum(meta.values()) / len(meta) - 1.0) < 0.001 + assert meta["TFT18_Lux"] > 1 > meta["TFT18_Shen"] + + +def test_no_rows_means_no_meta(): + assert tierlist.parse("nichts") == {} + assert tierlist.meta_strength({}, {"X": {"name": "X"}}) == {} diff --git a/backend/tests/test_traits.py b/backend/tests/test_traits.py new file mode 100644 index 0000000..d967784 --- /dev/null +++ b/backend/tests/test_traits.py @@ -0,0 +1,98 @@ +"""Trait-Boni: Zählung, Stufen, Reichweite des Bonus.""" + +from tft.sim import battle, traits + +UNITS = { + "A": {"traits": ["PANZER"], "stats": {"hp": 1000, "armor": 20, "damage": 50, + "attackSpeed": 1.0, "range": 1}}, + "B": {"traits": ["PANZER"], "stats": {"hp": 1000, "armor": 20, "damage": 50, + "attackSpeed": 1.0, "range": 1}}, + "C": {"traits": ["ANDERE"], "stats": {"hp": 1000, "armor": 20, "damage": 50, + "attackSpeed": 1.0, "range": 1}}, +} +TRAITS = { + "PANZER": {"breakpoints": [ + {"min_units": 2, "variables": {"DefenderDefenseGain": 25}}, + {"min_units": 3, "variables": {"DefenderDefenseGain": 60}}, + ]}, + "ANDERE": {"breakpoints": [ + {"min_units": 1, "variables": {"TeamManaRegen": 2}}, + ]}, +} + + +def board(*keys): + return [{"api_name": k, "stars": 1} for k in keys] + + +def fighters(*keys): + return [battle.to_fighter({"api_name": k, "stars": 1, "col": 0, "row": 0}, + "a", UNITS) for k in keys] + + +def test_a_trait_needs_enough_units(): + assert traits.active(board("A"), UNITS, TRAITS) == {} + assert "PANZER" in traits.active(board("A", "B"), UNITS, TRAITS) + + +def test_copies_of_the_same_champion_count_once(): + """Zwei Kopien von A sind ein Trait-Träger, nicht zwei.""" + assert traits.active(board("A", "A"), UNITS, TRAITS) == {} + + +def test_the_highest_reached_step_wins(): + step = traits.active(board("A", "B", "C"), UNITS, TRAITS)["PANZER"] + assert step["variables"]["DefenderDefenseGain"] == 25 + UNITS["C"]["traits"].append("PANZER") + try: + step = traits.active(board("A", "B", "C"), UNITS, TRAITS)["PANZER"] + assert step["variables"]["DefenderDefenseGain"] == 60 + finally: + UNITS["C"]["traits"].remove("PANZER") + + +def test_the_bonus_only_reaches_its_own_units(): + fa, fb, fc = fighters("A", "B", "C") + traits.apply([fa, fb, fc], board("A", "B", "C"), UNITS, TRAITS) + assert fa.armor == 45 and fb.armor == 45 # 20 + 25 + assert fc.armor == 20 + + +def test_team_bonuses_reach_everyone(): + """Steht "Team" im Namen, gilt der Bonus für das ganze Board.""" + fa, fb, fc = fighters("A", "B", "C") + traits.apply([fa, fb, fc], board("A", "B", "C"), UNITS, TRAITS) + assert fa.mana_regen == 2 and fc.mana_regen == 2 + + +def test_percent_and_flat_are_told_apart(): + """Derselbe Name trägt beide Skalen — unter 1 Anteil, darüber flach.""" + assert traits.effects_of({"ADAP": 0.3})["attack_damage_pct"] == 0.3 + assert traits.effects_of({"ADAP": 30})["attack_damage_pct"] == 0.3 + + +def test_unknown_variables_are_ignored(): + assert traits.effects_of({"OverrunCombats": 3, "RengarGold": 5}) == {} + + +def test_durability_reduces_incoming_damage(): + gains = traits.effects_of({"JuggernautDurability": 0.2}) + assert gains["durability"] == 0.2 + + +def test_the_panel_and_the_fight_count_the_same(): + """Zwei Kopien desselben Champions sind ein Trait-Träger. + + Zählt das UI anders als der Kampf, zeigt es Stufen an, die im + Gefecht nicht wirken. + """ + board = [{"api_name": "A", "stars": 1}, {"api_name": "A", "stars": 2}, + {"api_name": "B", "stars": 1}] + assert traits.counts(board, UNITS)["PANZER"] == 2 + step = traits.active(board, UNITS, TRAITS)["PANZER"] + assert step["units"] == 2 and step["step"] == 1 + + +def test_unknown_units_do_not_count(): + board = [{"api_name": "GIBTS_NICHT", "stars": 1}] + assert traits.counts(board, UNITS) == {} diff --git a/backend/tft/api/app.py b/backend/tft/api/app.py index c29d00c..65a3004 100644 --- a/backend/tft/api/app.py +++ b/backend/tft/api/app.py @@ -1,11 +1,17 @@ import json +import random +from collections import defaultdict import uuid from fastapi import FastAPI, HTTPException from pydantic import BaseModel from tft import paths -from tft.model.score import active_trait_tiers +from tft.model import artifact as artifact_mod +from tft.model import groups as groups_mod +from tft.model.rating import predict_placement +from tft.sim import traits as sim_traits +from tft.sim import arena as arena_mod from tft.sim import economy from tft.sim import player as sim_player from tft.sim.game import Game, InvalidAction @@ -23,6 +29,7 @@ class ActionRequest(BaseModel): item_idx: int | None = None board_idx: int | None = None choice: int | None = None + hex: int | None = None @app.get("/health") @@ -32,7 +39,6 @@ def health() -> dict: def _load_game_deps(): from tft.constants.loader import load_constants - from tft.model import artifact as artifact_mod pointer = paths.latest_static_pointer() if not pointer.exists(): @@ -41,6 +47,221 @@ def _load_game_deps(): return artifact_mod.load(set_number), load_constants(set_number) +@app.get("/api/groups") +def groups(size: int = 2, limit: int = 10) -> dict: + """Stärkste Champion-Gruppen, gemessen an echten Endboards.""" + artifact, _ = _load_game_deps() + table = (artifact.get("groups") or {}).get(str(size)) + if not table: + raise HTTPException(404, f"keine {size}er-Gruppen im Artefakt") + static = artifact["static"] + units, traits = static["units"], static["traits"] + tiers = artifact.get("unit_tiers") or {} + + # Je Carry nur die beste Gruppe — sonst steht derselbe Champion + # fünfmal in der Liste, nur mit anderem Beiwerk. + rows, seen = [], set() + for key, v in sorted(table.items(), key=lambda x: -x[1]["edge"]): + carry = next((t.split("@")[0] for t in key.split("|") if t.endswith("@3")), None) + if carry in seen: + continue + seen.add(carry) + rows.append((key, v)) + if len(rows) >= limit: + break + + def card(token: str) -> dict | None: + # Schlüssel sind "api@sterne@items". + api, stars, items = (token.split("@") + ["0", "0"])[:3] + if api not in units: + return None + return {**_unit_card(artifact, api), + "stars": int(stars), "items": int(items)} + + return { + "size": size, + "boards": artifact["groups"].get("boards"), + "sizes": sorted(int(k) for k in artifact["groups"] if k.isdigit()), + "groups": [ + # Carry zuerst: er trägt die Comp, der Rest stützt. + {"edge": round(v["edge"], 2), "boards": v["boards"], + "units": sorted((c for c in map(card, key.split("|")) if c), + key=lambda c: -c["items"])} + for key, v in rows + ], + } + + +@app.get("/api/roles") +def roles() -> dict: + """Stärkste Unit je Rolle und Kostenstufe, mit und ohne Items.""" + artifact, _ = _load_game_deps() + solo = (artifact.get("groups") or {}).get("solo") + if not solo: + raise HTTPException(404, "keine Einzelwerte im Artefakt") + units = artifact["static"]["units"] + + # Der Buchstabe und die Zahl müssen dasselbe messen — sonst steht auf + # einer Karte "S" neben einem negativen Wert. Beide kommen aus + # unit_tiers: drei Items, zwei Sterne. + rated = artifact.get("unit_tiers") or {} + + def best(role_needle: str, cost: int) -> dict | None: + table = rated + # Unter dem Schnitt ist keine Empfehlung. Ist die ganze Kostenstufe + # einer Rolle schwach, bleibt der Platz lieber leer. + pool = [(table[api]["strength"], api) + for api, unit in units.items() + if unit["cost"] == cost + and role_needle in (unit.get("role") or "") + and api in table + and table[api]["strength"] > 0] + if not pool: + return None + edge, api = max(pool) + return { + **_unit_card(artifact, api), + "edge": round(edge, 2), + "boards": table[api]["boards"], + "stars": groups_mod.REFERENCE_STARS, + } + + static_items = artifact["static"]["items"] + # Items hängen am Champion, nicht an der Rolle: das AD/AP-Label aus + # Riots Rollennamen traf nur in 68 % der Fälle. + items_by_unit = defaultdict(list) + for key, v in (artifact["groups"].get("unit_items") or {}).items(): + api, _, item = key.partition("|") + items_by_unit[api].append((v["edge"], v["boards"], item)) + + def items_for(api: str) -> list[dict]: + # Unter null ist keine Empfehlung — wie bei den Units selbst. + best_items = [x for x in sorted(items_by_unit.get(api, []), reverse=True) + if x[0] > 0][:groups_mod.TOP_ITEMS] + return [{"name": static_items.get(i, {}).get("name", i), + "icon": static_items.get(i, {}).get("icon"), + "edge": round(e, 2), "boards": n} + for e, n, i in best_items] + + def card(needle: str, cost: int) -> dict | None: + unit = best(needle, cost) + if unit: + unit["items"] = items_for(unit["api_name"]) + return unit + + return { + "boards": artifact["groups"].get("boards"), + "roles": [ + {"label": label, + "costs": [{"cost": c, "unit": card(needle, c)} for c in range(1, 6)]} + for label, needle in groups_mod.ROLE_GROUPS + ], + } + + +@app.get("/api/carries") +def carries() -> dict: + """Units, die als Carry gespielt werden — mit drei Items. + + Sortiert nach Vorsprung. "why" zerlegt ihn in drei Töpfe: liegt es am + Champion selbst, an seinen Traits oder an den Units daneben? + """ + artifact, _ = _load_game_deps() + table = (artifact.get("groups") or {}).get("carries") + if not table: + raise HTTPException(404, "keine Carry-Werte im Artefakt") + + static_items = artifact["static"]["items"] + items_by_unit = defaultdict(list) + for key, v in (artifact["groups"].get("unit_items") or {}).items(): + api, _, item = key.partition("|") + items_by_unit[api].append((v["edge"], v["boards"], item)) + + def items_for(api: str) -> list[dict]: + best = [x for x in sorted(items_by_unit.get(api, []), reverse=True) + if x[0] > 0][:groups_mod.TOP_ITEMS] + return [{"name": static_items.get(i, {}).get("name", i), + "icon": static_items.get(i, {}).get("icon"), + "edge": round(e, 2), "boards": n} for e, n, i in best] + + # Der Buchstabe vergleicht mit ALLEN Units, nicht nur mit den Carries — + # sonst bekäme der schwächste Carry ein D, obwohl er über dem Schnitt + # liegt. Die globalen Tiers taugen dafür nicht: sie messen 2★, Rerolls + # werden aber auf 3★ ausgewiesen. Hier stehen 2★ und 3★ auf derselben + # Skala, weil die übrigen 3★-Units mitkontrolliert sind. + # Sortiert wird nach dem Gesamtwert — die Frage ist "worauf spiele ich". + # Der Solo-Wert steht daneben und sagt, wie sehr der Carry an seiner + # Comp hängt: Elder Dragon liegt gesamt bei +0.26, mit schwacher + # Begleitung aber bei -0.16. Danach zu sortieren wäre falsch, dann + # fielen die stärksten Optionen ganz raus. + known = [api for api in table if api in artifact["static"]["units"]] + scale = sorted(known, key=lambda api: -table[api]["edge"]) + tier_of = {api: artifact_mod.tier_at((i + 0.5) / len(scale)) + for i, api in enumerate(scale)} + # Unter dem Schnitt ist keine Empfehlung — wie bei den Rollen. + ranked = [api for api in scale + if table[api]["carry"] and table[api]["edge"] > 0] + return { + "boards": artifact["groups"].get("boards"), + "carries": [ + {**_unit_card(artifact, api), + "tier": tier_of[api], + "edge": round(table[api]["edge"], 2), + "rate": table[api]["rate"], + "boards": table[api]["boards"], + "stars": table[api]["stars"], + "why": table[api].get("why"), + "items": items_for(api)} + for api in ranked + ], + } + + +@app.post("/api/game/{game_id}/replay") +def replay(game_id: str) -> dict: + """Der letzte Kampf des Spielers als Ereignis-Protokoll. + + Er wird während der Runde mitgeschrieben, nicht nachträglich neu + gerechnet — sonst zeigte die Anzeige einen anderen Kampf als den, + der das Leben gekostet hat. + """ + game = GAMES.get(game_id) + if game is None: + raise HTTPException(404, "no such game") + if not game.last_battle: + raise HTTPException(409, "noch kein Kampf gelaufen") + return game.last_battle + + +@app.get("/api/pairs") +def pairs() -> dict: + """Kleine Gruppen, die zusammengehören und stark sind. + + Bindung aus den Pro-Comps: wenn der seltenere zweier Champions + gespielt wird, wie oft ist der andere dabei? Stärke aus unseren + Endboards, mit derselben Vergleichsgruppe wie die Carries. + """ + artifact, _ = _load_game_deps() + table = artifact.get("pairs") + if not table: + raise HTTPException(404, "keine Paare im Artefakt") + known = artifact["static"]["units"] + traits = artifact["static"]["traits"] + return { + "comps": table["comps"], + "candidates": table["candidates"], + "min_bond": table["min_bond"], + "groups": [{"edge": g["edge"], "bond": g["bond"], "boards": g["boards"], + "links": g["links"], + "traits": [{"name": traits[t].get("name", t), + "icon": traits[t].get("icon")} + for t in g.get("traits") or [] if t in traits], + "units": [_unit_card(artifact, api) for api in g["units"]]} + for g in table["groups"] + if all(api in known for api in g["units"])], + } + + @app.post("/api/game") def new_game(seed: int | None = None) -> dict: artifact, cfg = _load_game_deps() @@ -73,6 +294,8 @@ def action(game_id: str, req: ActionRequest) -> dict: game.buy_xp() elif req.action == "move": game.move(req.where, req.idx) + elif req.action == "place": + game.place(req.where, req.idx, req.hex) elif req.action == "equip": game.equip(req.item_idx, req.board_idx) elif req.action == "pick_augment": @@ -109,6 +332,8 @@ def unit_view(game: Game, u: dict) -> dict: "name": info.get("name", u["api_name"]), "cost": info["cost"], "stars": u["stars"], + "hex": u.get("hex"), + **unit_strength(game.artifact, u), "traits": u.get("shown_traits", info["traits"]), "icon": info.get("icon"), "items": [ @@ -119,29 +344,94 @@ def unit_view(game: Game, u: dict) -> dict: } +def _unit_card(artifact: dict, api: str) -> dict: + """Karten-Daten einer Unit — dieselbe Darstellung wie im Shop. + + Das Tier beantwortet: mit passenden Items auf 2 Sternen — gehört der + Champion dann zu den besten Units im Spiel? + """ + static = artifact["static"] + unit = static["units"][api] + traits = static["traits"] + rated = (artifact.get("unit_tiers") or {}).get(api) or {} + return { + "api_name": api, + "name": unit.get("name", api), + "cost": unit["cost"], + "icon": unit.get("icon"), + "splash": unit.get("splash"), + "tier": rated.get("tier"), + "traits": [{"name": traits[t].get("name", t), "icon": traits[t].get("icon")} + for t in unit["traits"] if t in traits], + } + + +def lobby_rank(game: Game) -> int | None: + """Platz des Spielers, wenn die Runde jetzt entschieden würde.""" + mine = predict_placement(game.board, game.level, game.artifact) + if mine is None: + return None + alive = [b for b in game.bots if b.alive] + better = sum(1 for b in alive + if (predict_placement(b.board, b.level, game.artifact) or 9) < mine) + # Ausgeschiedene Gegner stehen schon fest hinter allen Lebenden. + return better + 1 + + +def unit_strength(artifact: dict, u: dict) -> dict: + """Angriff und Widerstand einer Unit, gleiche Skala wie das Board. + + Beides kommt aus der Kampf-Engine. tier/strength dagegen aus echten + Endboards; sie fehlen, solange zu wenige Boards vorliegen. + """ + s = arena_mod.strength([u], artifact) + rated = (artifact.get("unit_tiers") or {}).get(u["api_name"]) or {} + return {"off": round(s["off"], 1), "def": round(s["def"], 1), + "tier": rated.get("tier"), "strength": rated.get("strength")} + + +def carry_icon(game: Game, board: list[dict]) -> str | None: + """Teuerste Unit des Boards — steht in der Spielerliste fürs Portrait.""" + if not board: + return None + best = max(board, key=lambda u: sim_player.unit_worth(game.artifact, u)) + return game.artifact["static"]["units"][best["api_name"]].get("icon") + + +def trait_view(info: dict, api: str, count: int, tier: int) -> dict: + """Ein Trait fürs Panel: erreichte Stufe plus alle Schwellen mit ihrem Stil. + + style folgt Riot: 1 Bronze, 3 Silber, 4 Chromatisch, 5 Gold, 6 Prismatisch. + """ + breakpoints = info.get("breakpoints") or [] + return { + "api_name": api, + "name": info.get("name", api), + "count": count, + "tier": tier, + "style": breakpoints[tier - 1]["style"] if tier else 0, + "breakpoints": [{"units": bp["min_units"], "style": bp["style"]} + for bp in breakpoints], + "icon": info.get("icon"), + } + + def serialize(game: Game) -> dict: static = game.artifact["static"] - tiers = active_trait_tiers(game.board, static["traits"], static["units"]) - counts: dict[str, int] = {} - for u in game.board: - for t in set(static["units"][u["api_name"]]["traits"]): - counts[t] = counts.get(t, 0) + 1 + # Dieselbe Zählung wie im Kampf — sonst zeigt das Panel Stufen an, + # die im Gefecht gar nicht wirken. + tiers = {t: step["step"] + for t, step in sim_traits.active(game.board, static["units"], + static["traits"]).items()} + counts = sim_traits.counts(game.board, static["units"]) traits_panel = sorted( - ( - { - "api_name": t, - "name": static["traits"][t].get("name", t), - "count": n, - "tier": tiers.get(t, 0), - "breakpoints": [bp["min_units"] for bp in static["traits"][t]["breakpoints"]], - "icon": static["traits"][t].get("icon"), - } - for t, n in counts.items() - ), + (trait_view(static["traits"][t], t, n, tiers.get(t, 0)) + for t, n in counts.items()), key=lambda x: (-x["tier"], -x["count"]), ) stage = game.round["stage"] + strength = game.player_strength() return { "round": {"label": game.round["label"], "stage": stage, "kind": game.round["kind"]}, @@ -154,7 +444,13 @@ def serialize(game: Game) -> dict: "hp": game.hp, "gold": game.gold, "level": game.level, "xp": game.xp, "xp_needed": economy.xp_to_next(game.level, game.cfg), "streak": game.streak, - "score": round(game.player_score(), 1), + "off": round(strength["off"], 1), + "def": round(strength["def"], 1), + # Welchen Platz würde das Board jetzt machen? Nicht die rohe + # Vorhersage — die ist mitten im Spiel bei jedem ~8, weil + # Endboards mit fünf Units immer zu Ausgeschiedenen gehören. + # Verglichen wird deshalb mit den sieben Gegnern der Lobby. + "expected_place": lobby_rank(game), }, "board": [unit_view(game, u) for u in game.board], "bench": [unit_view(game, u) for u in game.bench], @@ -174,10 +470,15 @@ def serialize(game: Game) -> dict: for t in static["units"][api]["traits"] if t in static["traits"] ], "icon": static["units"][api].get("icon"), + "splash": static["units"][api].get("splash"), + **unit_strength(game.artifact, + {"api_name": api, "stars": 1, "items": []}), "upgrade": upgrade_hint(game, api), } for api in game.shop ], + "board_grid": {"rows": sim_player.BOARD_ROWS, "cols": sim_player.BOARD_COLS}, + "bench_size": sim_player.BENCH_SIZE, "shop_locked": game.shop_locked, "shop_odds": game.cfg["shop"]["odds"][game.level - 1], "traits": traits_panel, @@ -197,7 +498,10 @@ def serialize(game: Game) -> dict: "level": b.level if b.alive else None, "gold": b.gold if b.alive else None, "streak": b.streak if b.alive else None, - "score": round(sim_player.score(b, game.artifact), 1) if b.alive else None, + **({k: round(v, 1) + for k, v in sim_player.strength(b, game.artifact).items()} + if b.alive else {"off": None, "def": None}), + "carry": carry_icon(game, b.board), "board": [unit_view(game, u) for u in b.board], } for b in game.bots diff --git a/backend/tft/cli.py b/backend/tft/cli.py index 68a3c76..7270ae6 100644 --- a/backend/tft/cli.py +++ b/backend/tft/cli.py @@ -17,13 +17,27 @@ def main() -> None: p_fetch = sub.add_parser("fetch-static", help="download static data from Community Dragon") p_fetch.add_argument("--set", type=int, default=None, help="override set number") + # cdragon veröffentlicht die Champions eines neuen Sets erst spät; + # metatft hat sie ab PBE. --set ist dann Pflicht, es gibt keine Erkennung. + p_fetch.add_argument("--source", choices=["cdragon", "metatft"], default="cdragon") - sub.add_parser("crawl", help="crawl ranked matches from Challenger/GM ladder (max 200 neue pro Lauf)") + p_crawl = sub.add_parser("crawl", help="crawl ranked matches from the ladder") + p_crawl.add_argument("--limit", type=int, default=200, help="neue Matches pro Lauf") + p_crawl.add_argument("--since", type=int, default=None, metavar="TAGE", + help="nur Matches der letzten N Tage anfragen") sub.add_parser("extract", help="extract endboards from raw matches") sub.add_parser("build-artifact", help="build analysis.json from static data + endboards") + + p_tier = sub.add_parser("import-tierlist", + help="read a saved MetaTFT champion tier list for unit meta strength") + p_tier.add_argument("path", help="saved 'TFT Champion Tier List.html'") sub.add_parser("calibrate", help="backtest score vs real placements (holdout)") + p_pairs = sub.add_parser("pairs", help="Champion-Paare, die mehr bringen als ihre Teile") + p_pairs.add_argument("--top", type=int, default=15) + p_pairs.add_argument("--unit", default=None, help="nur Paare mit dieser Unit") + 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("--games", type=int, default=200) @@ -33,18 +47,27 @@ def main() -> None: args = parser.parse_args() if args.command == "fetch-static": - from tft.staticdata.fetch import fetch_static + from tft.staticdata.fetch import fetch_metatft, fetch_static - parsed = fetch_static(set_override=args.set) + if args.source == "metatft": + if args.set is None: + raise SystemExit("--source metatft needs an explicit --set") + parsed = fetch_metatft(args.set) + else: + parsed = fetch_static(set_override=args.set) meta = parsed["meta"] print(f"set {meta['set']} ({meta['set_name']}), patch {meta['patch']}") for name in ("units", "traits", "items", "augments"): print(f" {name}: {len(parsed[name])}") + if meta.get("incomplete_units"): + print(" ohne Basiswerte, ausgelassen: " + + ", ".join(meta["incomplete_units"])) elif args.command == "crawl": from tft.matches.crawl import crawl - added = crawl(set_number=current_set()) + added = crawl(set_number=current_set(), limit=args.limit, + since_days=args.since) print(f"{added} new matches stored") elif args.command == "extract": @@ -69,14 +92,113 @@ def main() -> None: from tft.model.learn import learn_from_db from tft.staticdata.fetch import load_static + from tft.model.baseline import unit_aliases + static = load_static() conn = db.connect() - learned = learn_from_db(conn, static["meta"]["set"]) + learned = learn_from_db(conn, static["meta"]["set"], + aliases=unit_aliases(static["units"])) conn.close() n_boards = learned.pop("n_boards", 0) art = artifact_mod.build(static, learned, {"boards_learned_from": n_boards}) + + from tft.model import carries as carries_mod + from tft.model import groups as groups_mod + from tft.model import pairs as pairs_mod + from tft.model.train_rating import train as train_rating + from tft.staticdata import procomps + + conn = db.connect() + art["rating"] = train_rating(conn, static["meta"]["set"], art) + art["groups"] = groups_mod.build(conn, static["meta"]["set"], + static["units"], art["item_pool"]) + # Carries brauchen eine eigene Kontrolle für die übrigen 3★-Units, + # deshalb eine eigene Tabelle statt eines Eintrags in groups. + boards = groups_mod.boards_from_db(conn, static["meta"]["set"], + static["units"]) + # Paare kommen aus den Pro-Comps, nicht aus den Boards: dort steht + # alles neben allem, was gerade im Shop kam. + art["groups"]["carries"] = carries_mod.build( + boards, static["units"], static["traits"], + artifact_mod.TIER_MIN_BOARDS) + # Bindung aus den Pro-Comps, Stärke aus denselben Boards und mit + # demselben Maßstab wie die Carries. + pro = procomps.comps(procomps.fetch(), static["units"]) + art["pairs"] = pairs_mod.build( + [c["units"] for c in pro], boards, + carries_mod.baseline(boards), + lambda b: carries_mod.cell(b, carries_mod.REFERENCE_STARS), + static["units"]) + conn.close() + # Tier = "drei passende Items, zwei Sterne" — die Definition, nach der + # man im Spiel plant. Eine unit-eigene Zielstufe wurde probiert und + # verworfen: der 3★-Wert misst nicht die Unit, sondern dass genug + # Gold und Leben da war, um sie zu treffen. Auf Level 9 steht Rengar + # auf 2★ bei -0.30 und auf 3★ bei +1.22 — das ist die Auswahl, nicht + # die Stärke. Auf fester Stufe liegen die 5-Kosten vorne. + carry = {} + for key, v in (art["groups"].get("solo") or {}).items(): + api, star, items = (key.split("@") + ["0", "0"])[:3] + if int(items) == groups_mod.MAX_ITEMS \ + and int(star) == groups_mod.REFERENCE_STARS: + carry[api] = v + art["unit_tiers"] = artifact_mod.unit_tiers( + {k: v["edge"] for k, v in carry.items()}, + {k: v["boards"] for k, v in carry.items()}, + static["units"]) path = artifact_mod.save(art) print(f"artifact written: {path} ({n_boards} boards learned from)") + if art["rating"]: + print(f" Boardbewertung: {len(art['rating']['trees'])} Bäume " + f"aus {art['rating']['boards']} Boards") + else: + print(" Boardbewertung: zu wenige Boards, übersprungen") + if art["groups"]: + print(" Gruppen: " + ", ".join( + f"{len(v)}x {k}er" for k, v in art["groups"].items() if k.isdigit())) + print(f" Tiers: {len(art['unit_tiers'])} Units") + + elif args.command == "import-tierlist": + import json as _json + from pathlib import Path + + from tft.staticdata import tierlist + from tft.staticdata.fetch import static_dir_of_latest + + rows = tierlist.parse(Path(args.path).read_text(errors="replace")) + if not rows: + raise SystemExit(f"no tier rows found in {args.path}") + out = static_dir_of_latest() / "tierlist.json" + out.write_text(_json.dumps(rows, indent=1, ensure_ascii=False)) + best = min(rows.items(), key=lambda r: r[1]["avg_place"]) + worst = max(rows.items(), key=lambda r: r[1]["avg_place"]) + print(f"{len(rows)} units -> {out}") + print(f" bester {best[0]} {best[1]['avg_place']}, " + f"schwächster {worst[0]} {worst[1]['avg_place']}") + print(" jetzt `make artifact` laufen lassen") + + elif args.command == "pairs": + from tft.model import artifact as artifact_mod + + art = artifact_mod.load(current_set()) + units = art["static"]["units"] + name = {api: u.get("name", api) for api, u in units.items()} + rows = [(name.get(k.split("|")[0], "?"), name.get(k.split("|")[1], "?"), + v["surplus"], v["boards"]) + for k, v in art["unit_pairs"].items()] + if args.unit: + needle = args.unit.lower() + rows = [r for r in rows if needle in r[0].lower() or needle in r[1].lower()] + rows.sort(key=lambda r: -r[2]) + if not rows: + raise SystemExit("keine Paare — erst crawl, extract und build-artifact") + print(f"{len(rows)} Paare mit genug Boards\n") + print(f"{'Paar':<30}{'Überschuss':>12}{'Boards':>9}") + for group, take in (("passen zusammen", rows[:args.top]), + ("passen nicht", rows[-args.top:])): + print(f"\n{group}:") + for a, b, s_, n in take: + print(f" {a + ' + ' + b:<28}{s_:>+12.3f}{n:>9}") elif args.command == "calibrate": from tft import db diff --git a/backend/tft/constants/set17.toml b/backend/tft/constants/set17.toml index 17ff2c3..48116e2 100644 --- a/backend/tft/constants/set17.toml +++ b/backend/tft/constants/set17.toml @@ -62,7 +62,10 @@ player_hp = 100 stage1 = ["carousel", "pve", "pve", "pve"] # Stages 2+: 7 Runden, Carousel bei x-4, PvE bei x-7. stage_n = ["pvp", "pvp", "pvp", "carousel", "pvp", "pvp", "pve"] -augment_rounds = ["2-1", "3-2", "4-2"] +# Augments sind abgeschaltet. Von 117 wirksamen Specs sind viele nur halb +# umgesetzt — Nachteile im Text fehlen, der Spieler bekommt reinen Vorteil. +# Zum Wiedereinschalten: ["2-1", "3-2", "4-2"]. +augment_rounds = [] [augments] # Tier-Gewichte [Silber, Gold, Prismatisch] pro Augment-Runde. diff --git a/backend/tft/constants/set18.toml b/backend/tft/constants/set18.toml new file mode 100644 index 0000000..ea40fb6 --- /dev/null +++ b/backend/tft/constants/set18.toml @@ -0,0 +1,88 @@ +# Spielregeln für Set 18 (Enchanted Wilds). +# Quelle, wo nicht anders vermerkt: MetaTFT-Regeltabellen +# data.metatft.com/lookups/pbe_TFTSet18_tables.json (Stand PBE, 16.08.2026). +# Die Tabellen sind Treppenfunktionen [ab_wert, wert]; hier ausgeschrieben. + +set = 18 + +[shop] +# Odds pro Level: [1-cost, 2-cost, 3-cost, 4-cost, 5-cost], Summe = 100. +# Direkt aus shopOdds. Nur Level 7 weicht von Set 17 ab (16/30/43 statt 19/30/40). +odds = [ + [100, 0, 0, 0, 0], # Level 1 + [100, 0, 0, 0, 0], # Level 2 + [75, 25, 0, 0, 0], # Level 3 + [55, 30, 15, 0, 0], # Level 4 + [45, 33, 20, 2, 0], # Level 5 + [30, 40, 25, 5, 0], # Level 6 + [16, 30, 43, 10, 1], # Level 7 + [15, 20, 32, 30, 3], # Level 8 + [10, 17, 25, 33, 15],# Level 9 + [5, 10, 20, 40, 25], # Level 10 +] +reroll_cost = 2 +slots = 5 + +[pool] +# shopPool.copiesByCost — 65 Units, 1227 Kopien insgesamt. +sizes = [30, 25, 18, 10, 9] +copies_for_2star = 3 +copies_for_3star = 9 + +[xp] +# expPerLevel.ExpBreakpoints; identisch mit Set 17. +start_level = 1 +to_next = [2, 2, 6, 10, 20, 36, 60, 68, 68] +passive_per_round = 2 +buy_amount = 4 +buy_cost = 4 +max_level = 10 + +[gold] +# passiveIncome.IncomeBasedOnRoundIndex, auf die Rundenfolge abgebildet. +# Stage 1 gibt weniger als in Set 17: 1-1 und 1-2 sind gänzlich golddfrei. +base_income = 5 +early_income = { "1-1" = 0, "1-2" = 0, "1-3" = 2, "1-4" = 2, "2-1" = 3, "2-2" = 4 } +# TODO: Zins steht nicht in den Tabellen — aus Set 17 übernommen. +interest_per_10 = 1 +interest_cap = 5 +win_bonus = 1 +# winStreakIncome/lossStreakIncome: ab 2er-Streak +1, ab 6er +3. +streak_gold = [[2, 1], [6, 3]] +starting_gold = 0 + +[damage] +# TODO: playerDamage fehlt in den Set-18-Tabellen — aus Set 17 übernommen. +stage_base = [0, 2, 5, 8, 10, 12, 17, 17] # Index 0 = Stage 1 +per_surviving_unit = 1 +player_hp = 100 + +[rounds] +# stages[]: Stage 1 ist 1x Encounter + 3 Intro-PvE, Stage 2+ sind +# 7 Runden mit Carousel bei x-4 und PvE bei x-7. +stage1 = ["carousel", "pve", "pve", "pve"] +stage_n = ["pvp", "pvp", "pvp", "carousel", "pvp", "pvp", "pve"] +# Augments bleiben abgeschaltet, solange die Specs nur halb umgesetzt sind. +# Zum Wiedereinschalten: augmentRounds aus den Tabellen = ["2-1", "3-2", "4-2"]. +augment_rounds = [] + +[augments] +# augmentTierDistribution nennt Kombinationen statt Gewichten pro Runde; +# ohne aktive Augments ungenutzt. Werte aus Set 17 als Platzhalter. +tier_weights = { "2-1" = [50, 45, 5], "3-2" = [35, 50, 15], "4-2" = [20, 55, 25] } + +[loot] +# TODO: lootTables sind deutlich feiner aufgebaut als unser Modell — +# vorerst die Set-17-Näherung. +stage1_pve = [1, 1] +stage_n_pve = [1, 2] +carousel = [1, 0] + +[stars] +# starMultipliers.Health = 1.8 exakt bestätigt. +# AD: damageByStar der Units ist durchgehend x1.5 je Stufe. +hp_multiplier = 1.8 +ad_multiplier = 1.5 + +[combat] +max_frames = 1800 diff --git a/backend/tft/db.py b/backend/tft/db.py index 3bccc86..8940954 100644 --- a/backend/tft/db.py +++ b/backend/tft/db.py @@ -22,7 +22,10 @@ CREATE TABLE IF NOT EXISTS endboards ( units TEXT NOT NULL, traits TEXT NOT NULL, augments TEXT NOT NULL, - PRIMARY KEY (match_id, puuid) + -- Nicht (match_id, puuid): Riot anonymisiert einzelne Spieler und gibt + -- dann mehrfach dieselbe puuid zurück. Mit puuid im Schlüssel fielen + -- deren Boards still unter den Tisch. Die Platzierung ist je Match eindeutig. + PRIMARY KEY (match_id, placement) ); CREATE TABLE IF NOT EXISTS crawl_log ( day TEXT PRIMARY KEY, @@ -35,4 +38,23 @@ def connect() -> sqlite3.Connection: paths.DATA.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(paths.MATCHES_DB) conn.executescript(SCHEMA) + _migrate_endboard_key(conn) return conn + + +def _migrate_endboard_key(conn: sqlite3.Connection) -> None: + """Alte Tabellen hatten puuid im Primärschlüssel und verloren Boards. + + endboards ist reiner Cache — die Wahrheit steht in matches.raw. Deshalb + genügt es, die Tabelle zu leeren; `extract` füllt sie wieder. + """ + sql = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='endboards'" + ).fetchone() + if not sql or "PRIMARY KEY (match_id, puuid)" not in sql[0]: + return + conn.executescript( + "DROP TABLE endboards;" + SCHEMA.split("CREATE TABLE IF NOT EXISTS endboards")[1] + .split(");")[0].join(["CREATE TABLE IF NOT EXISTS endboards", ");"]) + ) + conn.commit() diff --git a/backend/tft/matches/crawl.py b/backend/tft/matches/crawl.py index 40454c4..0c54706 100644 --- a/backend/tft/matches/crawl.py +++ b/backend/tft/matches/crawl.py @@ -1,6 +1,7 @@ """Crawl ranked TFT matches of Challenger/GM players into sqlite.""" import json +import time from datetime import date from tft import db @@ -9,20 +10,32 @@ from tft.matches.riot import RiotClient RANKED_QUEUE = 1100 -def crawl(set_number: int, limit: int = 200) -> int: +def crawl(set_number: int, limit: int = 200, since_days: int | None = None) -> int: + """since_days: nur Matches der letzten N Tage anfragen. + + Nach einem Set-Start ist das der billigste Filter — die alten Matches der + Spieler gehören noch zum Vorgänger-Set und würden je eine Anfrage kosten, + nur um dann verworfen zu werden. + """ client = RiotClient() conn = db.connect() seen = {row[0] for row in conn.execute("SELECT match_id FROM matches")} added = 0 - entries = client.ladder_entries() + # Ein Spieler liefert ~20 Match-Ids, aber die überschneiden sich stark: + # dieselben acht sitzen im selben Spiel. Lieber viele Spieler abklappern. + start_time = (int(time.time()) - since_days * 86400) if since_days else None + entries = client.ladder_entries(want=max(limit * 2, 200)) print(f"ladder: {len(entries)} players") + if not entries: + print("ladder empty — new set, nobody ranked yet?") + return 0 for entry in entries: if added >= limit: break puuid = client.puuid_for_entry(entry) - for match_id in client.match_ids(puuid): + for match_id in client.match_ids(puuid, start_time=start_time): if match_id in seen: continue if added >= limit: diff --git a/backend/tft/matches/extract.py b/backend/tft/matches/extract.py index 8c16822..15c65b9 100644 --- a/backend/tft/matches/extract.py +++ b/backend/tft/matches/extract.py @@ -48,13 +48,18 @@ def validate_ids(rows: list[tuple], static: dict) -> Counter: Summons/PvE-Units und Sonder-Items (Radiant, Ornn, ...) sind erwartbar unbekannt und werden ignoriert. """ + from tft.model.baseline import unit_aliases + unknown: Counter = Counter() known_items = set(static["items"]) | set(static["augments"]) + # Riot nennt Units beim Asset-Namen — ohne die Zuordnung wäre jede + # Set-18-Unit "unbekannt" und die Drift-Warnung nur Rauschen. + known_units = unit_aliases(static["units"]) set_prefix = f"TFT{static['meta']['set']}_" for row in rows: for u in json.loads(row[8]): api = u["character_id"] - if api not in static["units"]: + if api not in known_units: if not any(h in api.lower() for h in BENIGN_UNIT_HINTS): unknown[f"unit:{api}"] += 1 for item in u.get("itemNames", []): diff --git a/backend/tft/matches/riot.py b/backend/tft/matches/riot.py index 0e69a45..f7642c1 100644 --- a/backend/tft/matches/riot.py +++ b/backend/tft/matches/riot.py @@ -9,7 +9,7 @@ from tft import paths PLATFORM = os.environ.get("RIOT_PLATFORM", "euw1") REGION = os.environ.get("RIOT_REGION", "europe") -SLEEP = 1.3 # dev key: 100 req / 2 min +SLEEP = 1.2 # dev key: 100 req / 2 min def _load_env() -> None: @@ -59,11 +59,31 @@ class RiotClient: resp.raise_for_status() return resp.json() - def ladder_entries(self) -> list[dict]: + # Nach einem Set-Start ist die Leiter oben leer — niemand hat sich + # hochgespielt. Dann muss der Crawler tiefer greifen, sonst bekommt er + # nichts. Reihenfolge = absteigende Datenqualität. + LADDER_TIERS = ("challenger", "grandmaster", "master") + LADDER_DIVISIONS = ("DIAMOND", "EMERALD", "PLATINUM", "GOLD") + + def ladder_entries(self, want: int = 200) -> list[dict]: + """Spieler von der Ranglisten-Spitze abwärts, bis want erreicht ist.""" entries = [] - for tier in ("challenger", "grandmaster"): - league = self.get(PLATFORM, f"/tft/league/v1/{tier}") - entries.extend(league["entries"]) + for tier in self.LADDER_TIERS: + entries.extend(self.get(PLATFORM, f"/tft/league/v1/{tier}")["entries"]) + if len(entries) >= want: + return entries + # Eine Liga-Seite hat ~200 Spieler. Erst alle Ränge einer Liga, dann + # die nächste — je näher an der Spitze, desto sauberer die Daten. + for tier in self.LADDER_DIVISIONS: + for rank in ("I", "II", "III", "IV"): + for page in range(1, 6): + rows = self.get(PLATFORM, f"/tft/league/v1/entries/{tier}/{rank}", + {"page": page}) + entries.extend(rows) + if not rows or len(entries) >= want: + break + if len(entries) >= want: + return entries return entries def puuid_for_entry(self, entry: dict) -> str: @@ -74,9 +94,18 @@ class RiotClient: ) return summoner["puuid"] - def match_ids(self, puuid: str, count: int = 20) -> list[str]: + def match_ids(self, puuid: str, count: int = 20, + start_time: int | None = None) -> list[str]: + """Match-Ids eines Spielers, optional erst ab einem Zeitpunkt. + + start_time filtert bei Riot, nicht bei uns: jede Id, die wir gar nicht + erst bekommen, spart eine Anfrage aus dem Ratenlimit. + """ + params = {"count": count} + if start_time is not None: + params["startTime"] = start_time return self.get( - REGION, f"/tft/match/v1/matches/by-puuid/{puuid}/ids", {"count": count} + REGION, f"/tft/match/v1/matches/by-puuid/{puuid}/ids", params ) def match(self, match_id: str) -> dict: diff --git a/backend/tft/model/artifact.py b/backend/tft/model/artifact.py index fedd123..8e8b4b3 100644 --- a/backend/tft/model/artifact.py +++ b/backend/tft/model/artifact.py @@ -5,11 +5,14 @@ from datetime import date from tft import paths from tft.model import baseline +from tft.staticdata import tierlist -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 # Spatula/Pfanne-Rezepte (Embleme) fallen aus dem normalen Loot raus. -SPATULA_COMPONENTS = {"TFT_Item_Spatula", "TFT_Item_FryingPan"} +# Am Namensende erkannt, nicht am Präfix: das wechselt je Set +# (TFT_Item_Spatula in Set 17, DA_Component_Spatula in Set 18). +SPATULA_COMPONENTS = ("Spatula", "FryingPan") def craftable_items(static_items: dict) -> list[str]: @@ -17,11 +20,7 @@ def craftable_items(static_items: dict) -> list[str]: pool = [] for api, item in static_items.items(): comp = item.get("composition") or [] - if ( - len(comp) == 2 - and api.startswith("TFT_Item_") - and not set(comp) & SPATULA_COMPONENTS - ): + if len(comp) == 2 and not any(c.endswith(SPATULA_COMPONENTS) for c in comp): pool.append(api) return sorted(pool) @@ -47,32 +46,79 @@ def component_pool(static_items: dict) -> list[str]: 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 - - values = sorted( - p["spell_dps"] - for api, unit in static["units"].items() - if (p := statsheet.unit_stats( - unit, 1, [], static["items"], None, roles.get(api) - ))["spell_dps"] > 0 - ) - if not values: - return None - return values[int(len(values) * 0.9)] - - def _augment_specs(static: dict) -> dict: from tft.model.augments import build_specs return build_specs(static["augments"], static["units"]) +def unit_multiplier(learned: dict, static_units: dict) -> dict: + """Gelernte Stärke je Unit, innerhalb der Kostenstufe auf 1.0 zentriert. + + Roh misst der Multiplikator vor allem den Preis: 5-Kosten stehen nur auf + Boards, die Level 9 erreicht haben. Die Normierung je Kostenstufe nimmt + genau diesen Survivorship-Bias raus — übrig bleibt "gut für seinen Preis". + """ + raw = (learned or {}).get("units") or {} + by_cost: dict[int, list[str]] = {} + for api, unit in static_units.items(): + if api in raw: + by_cost.setdefault(unit["cost"], []).append(api) + + out = {} + for apis in by_cost.values(): + mean = sum(raw[a] for a in apis) / len(apis) + if mean <= 0: + continue + for api in apis: + out[api] = round(raw[api] / mean, 4) + return out + + +# Tier-Grenzen als Perzentil über alle Units. S heißt: mit passenden Items +# auf 2 Sternen gehört dieser Champion zu den stärksten im Spiel. Dass dabei +# fast nur 5-Kosten oben stehen, ist keine Verzerrung — es ist die Antwort. +TIER_BOUNDS = ((0.15, "S"), (0.40, "A"), (0.70, "B"), (0.88, "C"), (1.01, "D")) +# Unter so vielen Boards ist der Standardfehler so groß wie die echten +# Unterschiede. Dann lieber kein Buchstabe als ein geratener: Akali kam mit +# 210 Boards auf S, ihr Fehlerbalken war ±0.16 bei einem Wert von +0.18. +# +# 300 ist gemessen, nicht geschätzt. Im Halbierungstest über beide Hälften +# vergeben zwei Läufe damit in 82 % der Fälle denselben Buchstaben, bei 100 +# waren es 76 %. Höhere Schwellen wurden wieder schlechter, weil zu wenige +# Units übrig bleiben. Ein Filter auf "Wert über zwei Fehlerbalken" fiel auf +# 65 % — er entfernt gerade die Units in der Mitte und verschiebt dadurch +# alle Perzentil-Grenzen. +TIER_MIN_BOARDS = 300 + + +def tier_at(quantile: float) -> str: + """Perzentil -> Buchstabe. 0 ist der stärkste Rang.""" + return next(t for bound, t in TIER_BOUNDS if quantile < bound) + + +def unit_tiers(strengths: dict, boards: dict, static_units: dict) -> dict: + """api_name -> {"tier", "strength", "boards"}; Rang über alle Units. + + strength ist der Vorsprung in Plätzen gegenüber Boards mit gleichem + Level und gleicher Item-Menge, gemessen auf 2 Sternen mit drei Items. + Beide Kontrollen sind nötig: sonst misst man, wer weit gekommen ist. + + Der Buchstabe beantwortet: "Wenn ich passende Items draufpacke und ihn + auf 2 Sterne bringe — gehört er dann zu den besten Units im Spiel?" + Deshalb wird über alle Kosten hinweg verglichen, nicht innerhalb. + """ + apis = [api for api in static_units + if boards.get(api, 0) >= TIER_MIN_BOARDS and api in strengths] + ranked = sorted(apis, key=lambda a: strengths[a], reverse=True) + out = {} + for i, api in enumerate(ranked): + tier = tier_at((i + 0.5) / len(ranked)) + out[api] = {"tier": tier, "strength": round(strengths[api], 3), + "boards": boards[api]} + return out + + def build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict: roles = { api: baseline.classify_role(unit) for api, unit in static["units"].items() @@ -92,7 +138,17 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N "augments": static["augments"], }, "roles": roles, - "spell_dps_cap": spell_dps_cap(static, roles), + # Aus eigenen Endboards gelernte Stärke; 1.0 wo die Daten dünn sind. + "unit_multiplier": unit_multiplier(learned or {}, static["units"]), + # Paar-Überschuss: was zwei Units zusammen mehr bringen als einzeln. + "unit_pairs": (learned or {}).get("pairs") or {}, + # Tiers füllt der Aufrufer nach, sobald groups.build durch ist — + # sie brauchen dieselben bereinigten Werte wie der Rollen-Reiter. + "unit_tiers": {}, + # Meta-Stärke aus echten Platzierungen; leer, solange keine + # Tierlist importiert wurde. + "unit_meta": tierlist.meta_strength(static.get("tierlist") or {}, + static["units"]), "item_pool": craftable_items(static["items"]), "component_pool": component_pool(static["items"]), "recipes": recipes(static["items"]), @@ -101,6 +157,10 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N # der Sim nutzt alle geparsten Augments als Pool. "augment_pool": [], "learned": learned or {}, + # Gelernte Boardbewertung; füllt train_rating nach dem Bauen. + "rating": {}, + # Starke Champion-Gruppen; füllt groups.build nach dem Bauen. + "groups": {}, } @@ -124,4 +184,10 @@ def load(set_number: int) -> dict: f"no artifact for set {set_number}: run `build-artifact` first" ) path = json.loads(pointer.read_text())["path"] - return json.loads(open(path).read()) + art = json.loads(open(path).read()) + if art["meta"].get("schema_version") != SCHEMA_VERSION: + raise SystemExit( + f"artifact {path} has schema v{art['meta'].get('schema_version')}, " + f"expected v{SCHEMA_VERSION}: run `build-artifact` again" + ) + return art diff --git a/backend/tft/model/augments.py b/backend/tft/model/augments.py index 04028d5..1fde90e 100644 --- a/backend/tft/model/augments.py +++ b/backend/tft/model/augments.py @@ -6,9 +6,10 @@ Spieler-HP. Bedingte und Kampf-Spezialeffekte bleiben beim gelernten Fallback. import re -from tft.model.statsheet import _fraction -GOLD_KEYS = ("Gold", "InstantGold", "GoldNow", "GoldAmount") +# Reihenfolge = Priorität: "InitialGold" ist der Sofort-Betrag, "Gold" oft der +# bedingte Nachschlag (z.B. Survivor: InitialGold 4, Gold 100 nach 3 Toten). +GOLD_KEYS = ("InitialGold", "UpfrontGold", "InstantGold", "GoldNow", "GoldAmount", "Gold") XP_KEYS = ("XP", "InstantXP", "Experience", "UpfrontXP") COMPONENT_KEYS = ("NumComponents", "ComponentsToGive") ANVIL_KEYS = ("CompletedAnvils",) @@ -29,15 +30,23 @@ STAT_KEYS = { 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) + r"|(?:on|after) attack|takedown|dies|\bdie\b|eliminated|\bif\b" + r"|each round|after each", re.I) +SENTENCE_RE = re.compile(r"(?<=[.!])\s+") 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") +XP_PER_ROUND_RE = re.compile(r"[Gg]ain (\d+) XP each round", re.I) 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 _fraction(value: float) -> float: + """cdragon mischt Fraction (0.15) und Prozentzahl (15.0) — normalisieren.""" + return value if abs(value) <= 1.0 else value / 100 + + def _num(effects: dict, keys: tuple) -> float | None: for key in keys: val = effects.get(key) @@ -46,47 +55,67 @@ def _num(effects: dict, keys: tuple) -> float | None: return None +def unconditional_text(desc: str) -> str: + """Nur die bedingungsfreien Sätze — eine Bedingung gilt immer für ihren Satz. + + "Gain 4 gold. After 3 players are eliminated, gain 100 more gold." + -> "Gain 4 gold." + """ + return " ".join(s for s in SENTENCE_RE.split(desc) + if s.strip() and not CONDITIONAL_RE.search(s)) + + def build_spec(aug: dict, static_units: dict) -> dict: - desc = aug.get("desc") or "" + full = aug.get("desc") or "" effects = aug.get("effects") or {} atoms = [] - offerable = len(desc) >= 10 and "_Desc" not in desc and "@" not in desc + offerable = len(full) >= 10 and "_Desc" not in full and "@" not in full if not offerable: return {"atoms": [], "offerable": False} + # Ab hier zählt nur der unbedingte Teil. Steht irgendwo eine Bedingung, sind + # die effects-Zahlen nicht mehr eindeutig dem Sofort-Effekt zuzuordnen + # (Survivor: InitialGold 4 vs. Gold 100) — dann gilt nur der Text. + desc = unconditional_text(full) + num_effects = effects if desc == full.strip() else {} + 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))}) + "amount": int(_num(num_effects, PLAYER_HP_KEYS) or m.group(1))}) - gold = _num(effects, GOLD_KEYS) + gold = _num(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) + # Wiederkehrend, aber unbedingt ("each round"/"each stage"): eigene Atom-Art, + # daher am vollen Text — der Satzfilter wirft solche Sätze sonst raus. + m = GOLD_PER_STAGE_RE.search(full) if m: atoms.append({"kind": "gold_per_stage", "amount": int(m.group(1))}) + per_round = XP_PER_ROUND_RE.search(full) + if per_round: + atoms.append({"kind": "xp_per_round", "amount": int(per_round.group(1))}) - xp = _num(effects, XP_KEYS) - m = XP_RE.search(desc) + xp = None if per_round else _num(num_effects, XP_KEYS) + m = XP_RE.search(XP_PER_ROUND_RE.sub("", 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)}) + atoms.append({"kind": "xp_now", "amount": int(xp)}) - comps = _num(effects, COMPONENT_KEYS) + comps = _num(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) + anvils = _num(num_effects, ANVIL_KEYS) if anvils: atoms.append({"kind": "completed_items", "count": int(anvils)}) @@ -100,10 +129,10 @@ def build_spec(aug: dict, static_units: dict) -> dict: 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): + # Team-Stat-Buffs nur, wenn "your team gains" im unbedingten Teil steht. + if TEAM_RE.search(desc): for key, (stat, norm) in STAT_KEYS.items(): - val = effects.get(key) + val = num_effects.get(key) if not isinstance(val, (int, float)) or val == 0: continue if norm == "frac": diff --git a/backend/tft/model/baseline.py b/backend/tft/model/baseline.py index bd3d050..608484d 100644 --- a/backend/tft/model/baseline.py +++ b/backend/tft/model/baseline.py @@ -18,3 +18,18 @@ def classify_role(unit: dict) -> str: return "utility" stats = unit["stats"] return "frontline" if stats["range"] <= 1 else "ad_carry" + + +def unit_aliases(static_units: dict) -> dict: + """Alias -> api_name für Unit-Ids aus der Riot-Match-API. + + Riot nennt Units beim Asset-Namen: 'DA_18_GnarSmall' statt 'TFT18_Gnar', + 'DA_Karma18' statt 'TFT18_Karma'. Ohne die Zuordnung findet nichts aus + den Endboards zu unseren Statikdaten zurück. + """ + aliases = {} + for api, unit in static_units.items(): + aliases[api] = api + for alias in unit.get("asset_names") or []: + aliases[alias] = api + return aliases diff --git a/backend/tft/model/calibrate.py b/backend/tft/model/calibrate.py index 0db9215..d13407e 100644 --- a/backend/tft/model/calibrate.py +++ b/backend/tft/model/calibrate.py @@ -30,10 +30,23 @@ def spearman(a: list[float], b: list[float]) -> float: return cov / (va * vb) -def board_from_row(units_json: str, augments_json: str) -> tuple[list[dict], list[str]]: +def _level_of(board: list[dict]) -> int: + """Das Level hängt an den Board-Units; ohne Angabe ist die Unit-Zahl + die beste Schätzung.""" + return board[0].get("level", len(board)) if board else 1 + + +def board_from_row(units_json: str, augments_json: str, + aliases: dict | None = None) -> tuple[list[dict], list[str]]: + """Endboard-Zeile -> Board in unserer Schreibweise. + + Riot nennt Units beim Asset-Namen; ohne die Zuordnung findet score_board + keine einzige Unit wieder und jeder Score wäre 0. + """ + resolve = (aliases or {}).get units = [ { - "api_name": u["character_id"], + "api_name": resolve(u["character_id"], u["character_id"]), "stars": u["tier"], "items": u.get("itemNames", []), } @@ -43,19 +56,25 @@ def board_from_row(units_json: str, augments_json: str) -> tuple[list[dict], lis def _holdout_scores(conn, artifact: dict, holdout_only: bool, score_fn) -> dict: + from tft.model.baseline import unit_aliases + set_number = artifact["meta"]["set"] + aliases = unit_aliases(artifact["static"]["units"]) where = "WHERE set_number = ?" if holdout_only: # Holdout pro MATCH (nicht pro Board), sonst gibt es keine vollständigen 8er. where += " AND substr(match_id, -1) IN ('0', '5')" rows = conn.execute( - f"SELECT match_id, placement, units, augments FROM endboards {where}", + f"SELECT match_id, placement, level, units, augments FROM endboards {where}", (set_number,), ).fetchall() by_match: dict[str, list] = {} - for match_id, placement, units_json, augments_json in rows: - board, augments = board_from_row(units_json, augments_json) + for match_id, placement, level, units_json, augments_json in rows: + board, augments = board_from_row(units_json, augments_json, aliases) + # Das Level gehört zum Board — die Bewertung braucht es als Merkmal. + for u in board: + u["level"] = level by_match.setdefault(match_id, []).append( (placement, score_fn(board, augments, artifact)) ) @@ -64,9 +83,16 @@ def _holdout_scores(conn, artifact: dict, holdout_only: bool, score_fn) -> dict: def calibrate(conn, artifact: dict, holdout_only: bool = False, score_fn=None) -> dict: """Mean Spearman between board score and placement (negated: higher = better).""" - from tft.model.score import score_board + from tft.model.rating import board_rating - by_match = _holdout_scores(conn, artifact, holdout_only, score_fn or score_board) + if score_fn is None: + # Ohne trainiertes Modell gibt es nichts zu kalibrieren. Das alte + # Statmodell als Rückfall ist weg — es lag bei 0.648 gegen 0.885 + # und widersprach der Kampf-Engine. + def score_fn(board, augments, art): + return board_rating(board, _level_of(board), art) or 0.0 + + by_match = _holdout_scores(conn, artifact, holdout_only, score_fn) correlations = [] for players in by_match.values(): diff --git a/backend/tft/model/carries.py b/backend/tft/model/carries.py new file mode 100644 index 0000000..85d66ea --- /dev/null +++ b/backend/tft/model/carries.py @@ -0,0 +1,347 @@ +"""Die besten Carries — und woran ihr Vorsprung hängt. + +Ein Carry ist hier eine Unit mit drei Items. Gemessen wird der Vorsprung +gegenüber vergleichbaren Boards, und zwar mit drei Kontrollen: + + * Level — auf Level 9 ist jedes Board besser als auf Level 6. + * Item-Menge — wer mehr Items gesammelt hat, steht vorne. + * übrige 3★ — wer eine Unit auf 3★ hat, hatte Gold und Leben übrig. + +Die dritte Kontrolle ist der Grund, warum hier eine eigene Datei steht. +Ohne sie schlägt jeder 3★-Wert fast jeden 2★-Wert: Boards mit 3★-Rengar +tragen im Schnitt 1,72 weitere 3★-Units, Boards mit 2★-Rengar nur 0,66. +Die Kluft zwischen den Medianen sinkt dadurch von +0.80 auf +0.25, und +Kayle fällt von +0.87 auf +0.03 — bei ihr war fast alles Auswahl. +Der Halbierungstest bleibt dabei bei +0.99. + +Dazu die Frage "warum ist der stark". Ein Ridge-Modell auf dem +kontrollierten Vorsprung schätzt gleichzeitig: + + * je Unit "steht auf dem Board", "trägt drei Items", "ist 3★" + * je Trait die aktive Stufe + +Damit lässt sich der Vorsprung eines Carrys in drei Töpfe teilen — jeweils +als Abweichung von dem, was auf vergleichbaren Boards üblich ist: + + unit Ist der Champion selbst besser als ein durchschnittlicher Carry? + traits Bringen seine Boards stärkere Trait-Stufen mit? + rest Stehen bessere andere Units daneben? + +"rest" ist zugleich das Maß dafür, wie sehr ein Carry an seiner Comp +hängt. Geprüft wurde das an einer eigenen Zahl: dem Vorsprung im +schwächsten Drittel der Begleitung. Sie war mit r=0.94 fast eine Kopie +des Gesamtwerts und wurde deshalb wieder entfernt; was sie zusätzlich +sagte, deckt "rest" zu r=-0.48 ab. Elder Dragon liegt gesamt bei +0.26 +und dort bei -0.16, sein "rest" ist entsprechend +0.29. + +Geprüft: auf der zurückgehaltenen Hälfte sagt das Modell mit r=0.31 +vorher, die Koeffizienten stimmen zwischen beiden Hälften zu r=0.93, und +die Summe der drei Töpfe trifft die gemessene Zahl zu r=0.65. +""" + +from collections import defaultdict + +from tft.model.rating import trait_counts, trait_tiers + +# Schrumpfung: seltene Kombinationen werden Richtung 0 gezogen. +PSEUDO = 30 +MAX_ITEMS = 3 +MAX_STARS = 3 +REFERENCE_STARS = 2 +MAX_BOARD_ITEMS = 12 +# Mehr als vier 3★ auf einem Board landen in einem Topf. +MAX_OTHER_STARS = 4 +# So viele Boards braucht eine Vergleichsgruppe, damit ihr Mittel zählt. +MIN_CELL = 30 + +# Ab dieser Trägerquote gilt eine Unit als Carry. Nicht gewählt, sondern +# gemessen: über 63 Units mit genug Boards liegt die größte Lücke in der +# Verteilung zwischen 51 % und 57 %. +MIN_RATE = 0.5 +# Bis zu dieser Kostenstufe darf eine Unit auf 3★ ausgewiesen werden. +# Darüber ist die Zahl reine Auswahl: 4-Kosten auf 3★ holen in 87 % der +# Fälle Platz 1, 5-Kosten in 98 % — Ø-Platz 1.02. +REROLL_MAX_COST = 3 +RIDGE = 30.0 +# Enabler: so viele Boards braucht ein (Carry, Nachbar)-Paar je Seite, +# und so viele insgesamt, damit der Wert einer Unit zählt. +ENABLER_MIN_PAIR = 200 +ENABLER_MIN_TOTAL = 1500 + + +def prepare(boards: list[dict], static_units: dict, static_traits: dict) -> list[dict]: + """Vergleichsgruppe und Trait-Stufen an jedes Board hängen.""" + for b in boards: + stars3 = sum(1 for _, stars, _ in b["entities"] if stars == MAX_STARS) + b["stars3"] = stars3 + counts = trait_counts([{"api_name": api, "stars": stars} + for api, stars, _ in b["entities"]], static_units) + b["tiers"] = trait_tiers(counts, static_traits) + return boards + + +def cell(board: dict, own_stars: int) -> tuple: + """Vergleichsgruppe aus Sicht einer Unit — ihre eigenen 3★ zählen nicht.""" + others = board["stars3"] - (1 if own_stars == MAX_STARS else 0) + return (board["level"], board["items"], min(others, MAX_OTHER_STARS)) + + +def baseline(boards: list[dict]) -> dict: + """Ø-Platzierung je Vergleichsgruppe.""" + acc = defaultdict(list) + for b in boards: + for _, stars, _ in b["entities"]: + acc[cell(b, stars)].append(b["place"]) + return {key: sum(v) / len(v) for key, v in acc.items() if len(v) >= MIN_CELL} + + +def values(boards: list[dict], base: dict, static_units: dict, + min_boards: int, min_rate: float = MIN_RATE) -> dict: + """api_name -> Vorsprung mit drei Items, auf der gespielten Sternstufe. + + Enthält alle Units mit genug Boards, nicht nur die Carries — der + Buchstabe im Reiter braucht die ganze Liste als Vergleichsmaßstab. + """ + seen, full = defaultdict(int), defaultdict(int) + edges = defaultdict(list) + for b in boards: + for api, stars, items in b["entities"]: + seen[api] += 1 + key = cell(b, stars) + if items == MAX_ITEMS and key in base: + full[api] += 1 + edges[(api, stars)].append(base[key] - b["place"]) + + def shrunk(key): + v = edges.get(key) + return sum(v) / (len(v) + PSEUDO) if v else None + + out = {} + for api in seen: + two, three = shrunk((api, REFERENCE_STARS)), shrunk((api, MAX_STARS)) + stars = REFERENCE_STARS + # Auf 2★ negativ, auf 3★ positiv: ein Reroll-Champion. Ihn auf 2★ + # auszuweisen wäre falsch — so spielt ihn niemand. Nur wechseln, + # wenn die 3★-Zahl selbst auf genug Boards steht. + if (two is not None and three is not None and two < 0 < three + and static_units[api]["cost"] <= REROLL_MAX_COST + and len(edges[(api, MAX_STARS)]) >= min_boards): + stars = MAX_STARS + picked = edges.get((api, stars)) or [] + if len(picked) >= min_boards: + out[api] = {"edge": round(sum(picked) / (len(picked) + PSEUDO), 4), + "boards": len(picked), "stars": stars, + "rate": round(full[api] / seen[api], 3), + "carry": full[api] / seen[api] >= min_rate} + return out + + +def enabler_edge(boards: list[dict], base: dict, static_units: dict, + min_pair: int = ENABLER_MIN_PAIR, + min_total: int = ENABLER_MIN_TOTAL) -> dict: + """api_name -> wie viel besser fremde Carries laufen, wenn diese Unit dabei ist. + + Das ist die Ursache-Frage. Ein Topf "die anderen Units sind stark" + sagt nur, dass neben einem Carry starke Units stehen — nicht, ob er + sie stark macht oder umgekehrt. Hier wird derselbe Carry einmal mit + und einmal ohne die Unit gemessen, über alle Carries gemittelt. + + Halbierungstest: +0.92. Shen kommt so auf +0.41, obwohl er selbst + kaum Carry ist — genau das ist ein Stützer. + """ + pairs = defaultdict(lambda: (defaultdict(list), defaultdict(list))) + for b in boards: + key = cell(b, REFERENCE_STARS) + if key not in base: + continue + edge = base[key] - b["place"] + on = {api for api, _, _ in b["entities"]} + for carry, stars, items in b["entities"]: + if items != MAX_ITEMS: + continue + with_u, without = pairs[(carry, stars)] + for api in static_units: + if api == carry: + continue + (with_u if api in on else without)[api].append(edge) + + def shrunk(v): + return sum(v) / (len(v) + PSEUDO) + + acc = defaultdict(list) + for with_u, without in pairs.values(): + for api, values in with_u.items(): + other = without.get(api) or [] + if len(values) >= min_pair and len(other) >= min_pair: + acc[api].append((shrunk(values) - shrunk(other), len(values))) + out = {} + for api, parts in acc.items(): + n = sum(c for _, c in parts) + if n >= min_total: + out[api] = round(sum(d * c for d, c in parts) / n, 4) + return out + + +def supported(boards: list[dict], base: dict, lift: dict, + min_boards: int) -> dict: + """(api, stars) -> wie stark die Nachbarn genau diesen Carry heben. + + Summe der Stütz-Werte aller anderen Units auf dem Board, gegen das, + was auf vergleichbaren Boards üblich ist. + """ + ref = defaultdict(list) + rows = [] + for b in boards: + key = cell(b, REFERENCE_STARS) + if key not in base: + continue + total = sum(lift.get(api, 0.0) for api, _, _ in b["entities"]) + ref[key].append(total) + rows.append((b, key, total)) + mean = {k: sum(v) / len(v) for k, v in ref.items() if len(v) >= MIN_CELL} + acc = defaultdict(lambda: [0.0, 0]) + for b, key, total in rows: + if key not in mean: + continue + for api, stars, items in b["entities"]: + if items != MAX_ITEMS: + continue + e = acc[(api, stars)] + e[0] += (total - lift.get(api, 0.0)) - mean[key] + e[1] += 1 + return {k: round(v[0] / v[1], 3) for k, v in acc.items() if v[1] >= min_boards} + + +def _columns(boards: list[dict], static_units: dict) -> dict: + """Merkmale des Modells. + + "star2" fehlte zuerst, und das war der größte Fehler: ohne dieses + Merkmal sind ein 1★- und ein 2★-Draven für das Modell dasselbe. + Weil Carries auf 2★ gemessen werden, blieb genau ihr Vorteil + unerklärt — Dravens Rest lag bei +0.38. Mit dem Merkmal sind es + +0.03, und die Vorhersage auf der anderen Hälfte steigt von r=0.31 + auf r=0.44. Item-Merkmale wurden auch geprüft, sie bringen nur + r=+0.03 und bleiben deshalb draußen — Items haben ihre eigene Zahl. + """ + keys = [("on", u) for u in sorted(static_units)] + keys += [("carry", u) for u in sorted(static_units)] + keys += [("star2", u) for u in sorted(static_units)] + keys += [("star3", u) for u in sorted(static_units)] + keys += [("trait", tk) for tk in + sorted({(t, k) for b in boards for t, k in b["tiers"].items()})] + return {key: i for i, key in enumerate(keys)} + + +def _fit(boards: list[dict], base: dict, idx: dict, ridge: float): + """Ridge auf dem kontrollierten Vorsprung. Spalte 0 ist der Achsenabschnitt.""" + import numpy as np + + rows = [b for b in boards if cell(b, REFERENCE_STARS) in base] + X = np.zeros((len(rows), len(idx) + 1), dtype=np.float32) + y = np.empty(len(rows), dtype=np.float32) + for r, b in enumerate(rows): + X[r, 0] = 1.0 + y[r] = base[cell(b, REFERENCE_STARS)] - b["place"] + for api, stars, items in b["entities"]: + X[r, 1 + idx[("on", api)]] = 1.0 + if items == MAX_ITEMS: + X[r, 1 + idx[("carry", api)]] = 1.0 + if stars == MAX_STARS: + X[r, 1 + idx[("star3", api)]] = 1.0 + elif stars == REFERENCE_STARS: + X[r, 1 + idx[("star2", api)]] = 1.0 + for t, k in b["tiers"].items(): + col = idx.get(("trait", (t, k))) + if col is not None: + X[r, 1 + col] = 1.0 + pen = np.eye(X.shape[1], dtype=np.float32) * ridge + pen[0, 0] = 0.0 + return np.linalg.solve(X.T @ X + pen, X.T @ y) + + +def _weights(boards: list[dict], base: dict, static_units: dict, ridge: float): + """Modell einmal schätzen und den Beitrag einer Unit daraus ablesen.""" + idx = _columns(boards, static_units) + beta = [float(x) for x in _fit(boards, base, idx, ridge)] + + def own_of(api, stars, is_carry): + v = beta[1 + idx[("on", api)]] + if is_carry: + v += beta[1 + idx[("carry", api)]] + if stars == MAX_STARS: + v += beta[1 + idx[("star3", api)]] + elif stars == REFERENCE_STARS: + v += beta[1 + idx[("star2", api)]] + return v + + return beta, idx, own_of + + +def reasons(boards: list[dict], base: dict, own_of, idx: dict, beta: list, + min_boards: int) -> dict: + """api_name -> {"unit", "traits", "rest"} als Abweichung vom Üblichen. + + Rohe Koeffizienten wären nicht lesbar: jede Unit auf dem Board zieht + den Achsenabschnitt nach unten, dann ist "rest" immer stark negativ. + Deshalb wird jeder Topf gegen den Mittelwert seiner Vergleichsgruppe + gerechnet. + """ + ref_traits, ref_units, ref_own = (defaultdict(list) for _ in range(3)) + prepared = [] + for b in boards: + key = cell(b, REFERENCE_STARS) + traits = sum(beta[1 + idx[("trait", (t, k))]] + for t, k in b["tiers"].items() if ("trait", (t, k)) in idx) + units = {api: own_of(api, stars, items == MAX_ITEMS) + for api, stars, items in b["entities"]} + carries = [(api, stars) for api, stars, items in b["entities"] + if items == MAX_ITEMS] + prepared.append((key, carries, traits, units)) + ref_traits[key].append(traits) + ref_units[key].append(sum(units.values())) + for api, _ in carries: + ref_own[key].append(units[api]) + + def mean(table): + return {k: sum(v) / len(v) for k, v in table.items() if len(v) >= MIN_CELL} + + m_traits, m_units, m_own = mean(ref_traits), mean(ref_units), mean(ref_own) + acc = defaultdict(lambda: [0.0, 0.0, 0.0, 0]) + for key, carries, traits, units in prepared: + if key not in m_traits or key not in m_own: + continue + for api, stars in carries: + e = acc[(api, stars)] + e[0] += units[api] - m_own[key] + e[1] += traits - m_traits[key] + e[2] += (sum(units.values()) - units[api]) - (m_units[key] - m_own[key]) + e[3] += 1 + return {key: {"unit": round(v[0] / v[3], 3), "traits": round(v[1] / v[3], 3), + "rest": round(v[2] / v[3], 3), "boards": v[3]} + for key, v in acc.items() if v[3] >= min_boards} + + +def build(boards: list[dict], static_units: dict, static_traits: dict, + min_boards: int) -> dict: + """Carry-Werte plus Zerlegung. Leer, wenn zu wenige Boards da sind.""" + prepare(boards, static_units, static_traits) + base = baseline(boards) + if not base: + return {} + table = values(boards, base, static_units, min_boards) + if not table: + return {} + beta, idx, own_of = _weights(boards, base, static_units, RIDGE) + why = reasons(boards, base, own_of, idx, beta, min_boards) + lift = enabler_edge(boards, base, static_units) + prop = supported(boards, base, lift, min_boards) + for api, entry in table.items(): + key = (api, entry["stars"]) + part = why.get(key) + if part: + # "rest" fällt raus: es sagt nur, dass starke Units danebenstehen, + # nicht ob der Carry sie stark macht oder sie ihn. + entry["why"] = {"unit": part["unit"], "traits": part["traits"], + "supported": prop.get(key, 0.0), + "enabler": lift.get(api, 0.0)} + return table diff --git a/backend/tft/model/groups.py b/backend/tft/model/groups.py new file mode 100644 index 0000000..c81574b --- /dev/null +++ b/backend/tft/model/groups.py @@ -0,0 +1,223 @@ +"""Starke Champion-Gruppen aus echten Endboards. + +Eine "Einheit" ist hier Champion + Itemzahl: ein Carry mit drei Items ist +etwas anderes als derselbe Champion ohne. Gemessen wird der Vorsprung +gegenüber Boards mit demselben Level und derselben Item-Menge. + +Warum diese beiden Kontrollen: +* Ohne Level gewinnen nur Boards, die weit gekommen sind — auf einem + Endboard heißt "billige Units" meist "früh rausgeflogen". +* Ohne Item-Menge gewinnt schlicht, wer viele Items gesammelt hat. + Mit der Kontrolle bleibt die Frage: wer soll sie tragen? + +Halbierungstest über 64.000 Boards: 0.93 bei Dreiergruppen, 0.85 bei +Fünfern. Ein Überschuss-Maß (Gruppe minus Einzelwerte) wurde geprüft und +verworfen — es belohnt Gruppen aus einzeln schwachen Units. +""" + +import json +from collections import defaultdict +from itertools import combinations + +from tft.model.baseline import unit_aliases + +# Schrumpfung: seltene Gruppen werden Richtung 0 gezogen. +PSEUDO = 30 +MIN_BOARDS = 150 +SIZES = (1, 2, 3, 4, 5) +TOP_N = 60 +MAX_ITEMS = 3 +MAX_STARS = 3 +# Die Sternstufe für Vergleiche zwischen Units. 2★ ist der Normalfall: alle +# 63 Units haben dafür genug Boards, 3★ nur 40. +REFERENCE_STARS = 2 +# Mehr als 12 Items landen in einem Topf — darüber wird es zu dünn. +MAX_BOARD_ITEMS = 12 + + +# Riots Rollennamen zu den Begriffen, die Spieler benutzen. Reihenfolge +# zählt: die erste passende gewinnt, "AttackCaster" vor "Caster". +ROLE_GROUPS = ( + ("Tank", "Tank"), + ("Bruiser", "Fighter"), + ("Mage", "MagicCaster"), + ("Scharfschütze", "Marksman"), + ("Assassine", "Assassin"), + ("Caster", "AttackCaster"), + ("Spezialist", "Specialist"), +) + +# Ab so vielen Boards trauen wir einem Item auf einer bestimmten Unit. +# 150 waere sauberer, liesse aber billige Bruiser wie Akali ganz leer: +# ihr haeufigstes Item kommt auf 149 Boards. +ITEM_MIN_BOARDS = 120 +# So viele Items stehen auf einer Karte. +TOP_ITEMS = 3 +# Diebeshandschuhe belegen alle drei Plätze und würfeln den Inhalt jede +# Runde neu. Riot listet die gewürfelten Items daneben, als hätte sie +# jemand dorthin gelegt — 6,6 % aller Item-Beobachtungen kommen so +# zustande, bei Titan's Resolve sind es 16,7 %. Solche Träger zählen bei +# den Item-Empfehlungen nicht mit. Am Namensende erkannt, weil das Präfix +# je Set wechselt. +THIEFS_GLOVES = "ThiefsGloves" + + +def role_of(unit: dict) -> str | None: + role = unit.get("role") or "" + return next((label for label, needle in ROLE_GROUPS if needle in role), None) + + +def boards_from_db(conn, set_number: int, static_units: dict) -> list[dict]: + alias = unit_aliases(static_units) + rows = conn.execute( + "SELECT placement, level, units FROM endboards WHERE set_number = ?", + (set_number,), + ).fetchall() + out = [] + for place, level, units_json in rows: + entities, carried, items = set(), [], 0 + for u in json.loads(units_json): + api = alias.get(u["character_id"], u["character_id"]) + held = u.get("itemNames") or [] + items += len(held) + if api not in static_units: + continue + stars = min(u["tier"], MAX_STARS) + entities.add((api, stars, min(len(held), MAX_ITEMS))) + # Die Itemzahl gehört zur Vergleichsgruppe, die Items selbst + # sind das, was bewertet wird. + carried.append((api, stars, min(len(held), MAX_ITEMS), set(held))) + out.append({"place": place, "level": level, + "items": min(items, MAX_BOARD_ITEMS), + "entities": sorted(entities), + "carried": carried}) + return out + + +def baseline(boards: list[dict]) -> dict: + """Ø-Platzierung je (Level, Item-Menge) — der Maßstab für den Vorsprung.""" + acc = defaultdict(list) + for b in boards: + acc[(b["level"], b["items"])].append(b["place"]) + return {key: sum(v) / len(v) for key, v in acc.items()} + + +def group_edge(boards: list[dict], size: int, base: dict, + min_boards: int = MIN_BOARDS) -> dict: + """Gruppe -> {"edge", "boards"}. edge = Plätze besser als vergleichbare Boards.""" + acc = defaultdict(list) + for b in boards: + if len(b["entities"]) < size: + continue + edge = base[(b["level"], b["items"])] - b["place"] + for combo in combinations(b["entities"], size): + acc[combo].append(edge) + return { + "|".join(f"{api}@{stars}@{items}" for api, stars, items in key): { + "edge": round(sum(v) / (len(v) + PSEUDO), 4), "boards": len(v)} + for key, v in acc.items() if len(v) >= min_boards + } + + +def one_carry(key: str) -> bool: + """Genau ein Champion trägt drei Items. + + Gruppen mit zwei vollen Carries sind fast immer Endgame-Boards. Für die + Frage "worauf baue ich auf" taugt nur die Form, die im Spiel vorkommt: + ein Carry, der Rest stützt.""" + return sum(1 for token in key.split("|") + if token.endswith(f"@{MAX_ITEMS}")) == 1 + + +def unit_item_edge(boards: list[dict], craftable: set, + min_boards: int = ITEM_MIN_BOARDS) -> dict: + """(Champion, Item) -> Vorsprung. Welches Item gehört auf diesen Champion? + + Verglichen wird gegen denselben Champion auf derselben Sternstufe mit + derselben Anzahl Items. Ohne diese Kontrolle misst man vor allem, dass + ein Champion mit Items der Carry ist — dann sieht jedes Item gut aus. + + Vorher lief das je Rolle statt je Champion. Das AD/AP-Label kam aus + Riots Rollennamen und stimmte nur in 68 von 100 Fällen; zwei andere + Quellen im Datensatz kamen auf denselben Wert. Dazu bestand eine Familie + oft aus einem einzigen Champion mit genug Boards. Je Champion gemessen + trifft die Empfehlung 41 % des erreichbaren Gewinns, MetaTFTs rohe + Zahlen nur 23 %. + + Nur normal baubare Items: zwei Komponenten, keine Spatula. Embleme, + Artefakte und Radiants kann man nicht planen. + """ + per_item, per_cell = defaultdict(list), defaultdict(list) + for b in boards: + edge = b["_edge"] + for api, stars, items, held in b["carried"]: + if any(item.endswith(THIEFS_GLOVES) for item in held): + continue + cell = (api, stars, items) + per_cell[cell].append(edge) + for item in held: + if item in craftable: + per_item[(cell, item)].append(edge) + + def shrunk(values): + return sum(values) / (len(values) + PSEUDO) + + cell_mean = {key: shrunk(v) for key, v in per_cell.items()} + acc = defaultdict(list) + for (cell, item), values in per_item.items(): + acc[(cell[0], item)].append((shrunk(values) - cell_mean[cell], len(values))) + + out = {} + for (api, item), parts in acc.items(): + total = sum(n for _, n in parts) + if total >= min_boards: + out[f"{api}|{item}"] = { + "edge": round(sum(d * n for d, n in parts) / total, 4), + "boards": total, + } + return out + + +def unit_edge(boards: list[dict], base: dict, min_boards: int = MIN_BOARDS, + stars: int = REFERENCE_STARS) -> dict: + """api_name -> Vorsprung auf einer festen Sternstufe. + + Ohne die Sternstufe vergleicht man Äpfel mit Birnen: eine 1-Kosten-Unit + steht auf Level 9 fast nur noch als 3★ auf dem Board, ein 5-Kosten fast + immer als 1★. Der Rohvergleich lässt die billige Unit dann besser + aussehen, als sie ist. + """ + acc = defaultdict(list) + for b in boards: + edge = base[(b["level"], b["items"])] - b["place"] + for api, unit_stars, _ in b["entities"]: + if unit_stars == stars: + acc[api].append(edge) + return {api: {"edge": round(sum(v) / (len(v) + PSEUDO), 4), "boards": len(v)} + for api, v in acc.items() if len(v) >= min_boards} + + +def build(conn, set_number: int, static_units: dict, + craftable: set | None = None) -> dict: + """Die besten Gruppen je Größe. Leer, solange zu wenige Boards da sind.""" + boards = boards_from_db(conn, set_number, static_units) + if len(boards) < MIN_BOARDS * 20: + return {} + base = baseline(boards) + out = {"boards": len(boards)} + # Einzelne Units bleiben vollständig — der Rollen-Reiter braucht alle, + # nicht nur die besten. Erst ab zwei wird auf einen Carry gefiltert. + out["solo"] = group_edge(boards, 1, base, min_boards=MIN_BOARDS) + out["units"] = unit_edge(boards, base) + for b in boards: + b["_edge"] = base[(b["level"], b["items"])] - b["place"] + out["unit_items"] = unit_item_edge(boards, set(craftable or ())) + for size in SIZES: + if size < 2: + continue + table = {k: v for k, v in group_edge(boards, size, base).items() + if one_carry(k)} + best = sorted(table.items(), key=lambda x: -x[1]["edge"])[:TOP_N] + if best: + out[str(size)] = dict(best) + return out diff --git a/backend/tft/model/learn.py b/backend/tft/model/learn.py index faed6d2..5225ffe 100644 --- a/backend/tft/model/learn.py +++ b/backend/tft/model/learn.py @@ -9,11 +9,46 @@ from collections import defaultdict MEAN_PLACEMENT = 4.5 PSEUDO_COUNT = 30 -# Platzierungs-Delta -> Multiplikator: 1 Platz besser als der Schnitt ≈ +6 %. -DELTA_SCALE = 0.06 -MULT_CAP = (0.8, 1.25) -PAIR_MIN_N = 20 -PAIR_LIFT_CAP = (-0.9, 1.15) +# Platzierungs-Delta -> Multiplikator je Unit: 1 Platz besser ≈ +50 %. +# +# Der Faktor ist gemessen, nicht geschätzt. Zwei unabhängige Belege: +# * Über 1872 Endboards liegen zwischen zwei Plätzen 7,5 % Boardstärke. +# Ein Board trägt acht Units, pro Unit sind das rund 60 %. +# * Im Holdout bleibt die Rangkorrelation bis 0,5 unverändert (0,663 statt +# 0,666) und fällt darüber ab; die Übereinstimmung mit der MetaTFT-Tierlist +# steigt bis dahin von 0,17 auf 0,39. +DELTA_SCALE = 0.5 +# Deckel fängt nur dünne Daten ab — bei 1872 Boards liegt das 1.-99. Perzentil +# zwischen 0,50 und 1,32. +MULT_CAP = (0.5, 1.6) +# Ab so vielen gemeinsamen Boards trauen wir einem Paarwert. +PAIR_MIN_N = 30 + + +def value(placements: list[int], pseudo: int = PSEUDO_COUNT) -> float: + """Stärke aus Platzierungen: Abstand zum Schnitt, gegen 4.5 geschrumpft. + + Größer ist besser. Mit wenigen Boards zieht die Schrumpfung den Wert + Richtung 0, also Richtung "unauffällig". + """ + total = sum(placements) + MEAN_PLACEMENT * pseudo + return MEAN_PLACEMENT - total / (len(placements) + pseudo) + + +def pair_surplus(pair_placements: dict, unit_values: dict) -> dict: + """Wie viel besser läuft ein Paar, als seine zwei Units einzeln hergeben? + + Der rohe Paarwert steckt zu 81 % schon in den Einzelwerten — ein Paar + sieht gut aus, sobald ein starker Carry dabei ist. Erst der Überschuss + misst das Zusammenspiel. Halbierungstest: 0.95, also kein Rauschen. + """ + out = {} + for (a, b), placements in pair_placements.items(): + if len(placements) < PAIR_MIN_N or a not in unit_values or b not in unit_values: + continue + surplus = value(placements) - unit_values[a] - unit_values[b] + out[f"{a}|{b}"] = {"surplus": round(surplus, 4), "boards": len(placements)} + return out def _multiplier(placements: list[int]) -> float: @@ -23,24 +58,24 @@ def _multiplier(placements: list[int]) -> float: return min(max(mult, MULT_CAP[0]), MULT_CAP[1]) -def learn(rows: list[tuple]) -> dict: - """rows: (placement, units_json, traits_json, augments_json).""" +def learn(rows: list[tuple], aliases: dict | None = None) -> dict: + """rows: (placement, units_json, traits_json, augments_json). + + aliases bildet die Unit-Ids der Match-API auf unsere api_names ab — + Riot liefert 'DA_18_GnarSmall', unsere Statik kennt 'TFT18_Gnar'. + """ + resolve = (aliases or {}).get trait_placements = defaultdict(list) item_placements = defaultdict(list) augment_placements = defaultdict(list) unit_placements = defaultdict(list) - unit_top4 = defaultdict(int) - pair_top4 = defaultdict(int) + pair_placements = defaultdict(list) n_boards = 0 - n_top4 = 0 for placement, units_json, traits_json, augments_json in rows: n_boards += 1 - top4 = placement <= 4 - n_top4 += top4 - units = json.loads(units_json) - names = sorted({u["character_id"] for u in units}) + names = sorted({resolve(u["character_id"], u["character_id"]) for u in units}) for name in names: unit_placements[name].append(placement) for u in units: @@ -52,27 +87,18 @@ def learn(rows: list[tuple]) -> dict: for a in json.loads(augments_json): augment_placements[a].append(placement) - if top4: - for name in names: - unit_top4[name] += 1 - for i, a in enumerate(names): - for b in names[i + 1 :]: - pair_top4[(a, b)] += 1 + for i, a in enumerate(names): + for b in names[i + 1:]: + pair_placements[(a, b)].append(placement) - pairs = {} - if n_top4: - for (a, b), n_ab in pair_top4.items(): - if n_ab < PAIR_MIN_N: - continue - expected = unit_top4[a] * unit_top4[b] / n_top4 - if expected <= 0: - continue - lift = n_ab / expected - 1.0 - lift = min(max(lift, PAIR_LIFT_CAP[0]), PAIR_LIFT_CAP[1]) - pairs[f"{a}|{b}"] = round(lift, 4) + unit_values = {k: value(v) for k, v in unit_placements.items()} + pairs = pair_surplus(pair_placements, unit_values) return { "units": {k: round(_multiplier(v), 4) for k, v in unit_placements.items()}, + # Wie viele Boards hinter jedem Wert stehen — entscheidet, ob wir uns + # ein Tier überhaupt zutrauen. + "unit_boards": {k: len(v) for k, v in unit_placements.items()}, "traits": {k: round(_multiplier(v), 4) for k, v in trait_placements.items()}, "items": {k: round(_multiplier(v), 4) for k, v in item_placements.items()}, "augments": {k: round(_multiplier(v), 4) for k, v in augment_placements.items()}, @@ -81,7 +107,8 @@ def learn(rows: list[tuple]) -> dict: } -def learn_from_db(conn, set_number: int, exclude_holdout: bool = True) -> dict: +def learn_from_db(conn, set_number: int, exclude_holdout: bool = True, + aliases: dict | None = None) -> dict: where = "WHERE set_number = ?" if exclude_holdout: where += " AND substr(match_id, -1) NOT IN ('0', '5')" @@ -89,4 +116,4 @@ def learn_from_db(conn, set_number: int, exclude_holdout: bool = True) -> dict: f"SELECT placement, units, traits, augments FROM endboards {where}", (set_number,), ).fetchall() - return learn(rows) + return learn(rows, aliases) diff --git a/backend/tft/model/pairs.py b/backend/tft/model/pairs.py new file mode 100644 index 0000000..5f4050d --- /dev/null +++ b/backend/tft/model/pairs.py @@ -0,0 +1,156 @@ +"""Kleine Gruppen, die man fast immer zusammen stellt — und die stark sind. + +Zwei Bedingungen, aus zwei Quellen: + + Bindung Aus den Pro-Comps von MetaTFT. Stehen die Champions dort fast + immer zusammen? Endboards taugen dafür nicht: dort steht alles + neben allem, was gerade im Shop kam. Eine gebaute Comp zeigt + die Absicht. + + Stärke Aus unseren 82.000 Endboards. Laufen Boards mit dieser Gruppe + besser als vergleichbare? Kontrolliert nach Level, Item-Menge + und übrigen 3★-Units, wie bei den Carries. + +Gewicht einer Bindung: wie oft beide in derselben Comp-Stufe stehen, +geteilt durch die Nennungen des selteneren. Also "wenn der seltenere +gespielt wird, wie oft ist der andere dabei?". Rohe Zählung würde nur +die häufigen Champions nach oben spülen. Dazu eine Schrumpfung, sonst +käme jedes Einzelvorkommen auf 1.00. + +Eine Gruppe zählt nur, wenn JEDES Paar darin die Schwelle schafft — bei +einem Dreier also alle drei. Sonst hängt ein Dritter an nur einem der +beiden und wird trotzdem mitgezählt. + +Dazu muss die ganze Gruppe einen Trait teilen. Ohne diese Bedingung kam +Unsinn heraus: eine Comp hat acht bis neun Units, zwei davon stehen +nebeneinander, ohne etwas miteinander zu tun zu haben. Sieben der zwölf +Gruppen hatten keinen gemeinsamen Trait — darunter Cassiopeia, Lillia +und Ornn. + +Am Ende werden überlappende Gruppen verworfen: die stärkste gewinnt, +alle weiteren mit denselben Champions fallen weg. Ohne das steht +zwanzigmal dieselbe Cassiopeia-Gruppe in leichten Abwandlungen da. + +Halbierungstest über 268 Kandidaten: +0.98. +""" + +from collections import Counter, defaultdict +from itertools import combinations + +# Ein Paar mit einer einzigen Nennung soll nicht wie eine feste Regel wirken. +PSEUDO = 3 +# Ab dieser Bindung gilt ein Paar als "gehört zusammen". +MIN_BOND = 0.4 +# Gruppengrößen, die wir anbieten. +SIZES = (2, 3) +# So viele Boards braucht eine Gruppe, damit ihre Zahl zählt. +MIN_BOARDS = 300 +# Schrumpfung der Stärke, wie bei den Carries. +EDGE_PSEUDO = 30 + + +def counts(groups: list[list[str]]) -> tuple[Counter, Counter]: + """Nennungen je Champion und je Paar.""" + seen, pair = Counter(), Counter() + for units in groups: + seen.update(units) + for x, y in combinations(sorted(set(units)), 2): + pair[(x, y)] += 1 + return seen, pair + + +def weights(groups: list[list[str]], pseudo: int = PSEUDO) -> dict: + """(x, y) -> Bindung zwischen 0 und 1, x < y.""" + seen, pair = counts(groups) + return {key: count / (min(seen[key[0]], seen[key[1]]) + pseudo) + for key, count in pair.items()} + + +def shared_traits(group: tuple, static_units: dict) -> list[str]: + """Traits, die alle in der Gruppe haben.""" + sets = [set(static_units[api].get("traits") or ()) for api in group] + return sorted(set.intersection(*sets)) if sets else [] + + +def candidates(bond: dict, static_units: dict, min_bond: float = MIN_BOND, + sizes: tuple = SIZES) -> list[tuple]: + """Gruppen mit gemeinsamem Trait, in denen jedes Paar die Schwelle schafft.""" + def w(x, y): + return bond.get((x, y) if x < y else (y, x), 0.0) + + strong = [key for key, value in bond.items() if value >= min_bond] + nodes = sorted({u for key in strong for u in key} & set(static_units)) + out = [] + for size in sizes: + for group in combinations(nodes, size): + if (min(w(x, y) for x, y in combinations(group, 2)) >= min_bond + and shared_traits(group, static_units)): + out.append(group) + return out + + +def strength(boards: list[dict], base: dict, cell, groups: list[tuple], + min_boards: int = MIN_BOARDS) -> dict: + """Gruppe -> Vorsprung gegenüber vergleichbaren Boards. + + `cell` bestimmt die Vergleichsgruppe eines Boards; sie kommt aus dem + Carry-Modul, damit beide Reiter denselben Maßstab benutzen. + """ + acc = defaultdict(list) + for board in boards: + key = cell(board) + if key not in base: + continue + edge = base[key] - board["place"] + present = {api for api, _, _ in board["entities"]} + for group in groups: + if set(group) <= present: + acc[group].append(edge) + return {group: {"edge": round(sum(v) / (len(v) + EDGE_PSEUDO), 4), + "boards": len(v)} + for group, v in acc.items() if len(v) >= min_boards} + + +def select(scored: dict) -> list[tuple]: + """Stärkste zuerst, überlappende verwerfen. + + Nur positive Gruppen — unter dem Schnitt ist keine Empfehlung. + """ + order = sorted(scored, key=lambda g: -scored[g]["edge"]) + used, out = set(), [] + for group in order: + if scored[group]["edge"] <= 0 or used & set(group): + continue + used.update(group) + out.append(group) + return out + + +def build(pro_groups: list[list[str]], boards: list[dict], base: dict, cell, + static_units: dict, min_bond: float = MIN_BOND) -> dict: + """Die Gruppen für den Reiter. Leer ohne Pro-Comps oder ohne Boards.""" + if not pro_groups or not boards: + return {} + bond = weights(pro_groups) + _, pair = counts(pro_groups) + cands = candidates(bond, static_units, min_bond) + scored = strength(boards, base, cell, cands) + if not scored: + return {} + + def w(x, y): + return bond.get((x, y) if x < y else (y, x), 0.0) + + out = [] + for group in select(scored): + links = [{"units": [x, y], "bond": round(w(x, y), 3), + "comps": pair.get((x, y) if x < y else (y, x), 0)} + for x, y in combinations(group, 2)] + out.append({"units": list(group), + "edge": scored[group]["edge"], + "boards": scored[group]["boards"], + "bond": round(min(l["bond"] for l in links), 3), + "traits": shared_traits(group, static_units), + "links": links}) + return {"groups": out, "comps": len(pro_groups), + "candidates": len(scored), "min_bond": min_bond} diff --git a/backend/tft/model/rating.py b/backend/tft/model/rating.py new file mode 100644 index 0000000..354bf59 --- /dev/null +++ b/backend/tft/model/rating.py @@ -0,0 +1,148 @@ +"""Boardbewertung aus echten Endboards: welche Platzierung ist zu erwarten? + +Ein Statmodell rechnet Off/Def aus HP, Schaden und Rüstung. Für +den Kampf ist das richtig, für die Frage "wie stark ist dieses Board" nicht: +gegen echte Platzierungen gemessen kommt es auf 0.65 Rangkorrelation — der +nackte Boardwert (Kosten × 3^Sterne) schafft ohne jedes Modell schon 0.74. + +Dieses Modul lernt stattdessen direkt aus Endboards. Es sagt die Platzierung +vorher (1 bis 8, kleiner ist besser) und erreicht dabei 0.885. Die Obergrenze +liegt bei etwa 0.95: identische Boards landen im Schnitt 0.84 Plätze +auseinander, das ist Kampfzufall und die Frage, wer gegen wen antritt. + +Trainiert wird in train_rating.py (braucht numpy), ausgewertet hier — der +Baumdurchlauf ist ein paar hundert Vergleiche und läuft ohne Abhängigkeiten. +""" + +import math +from collections import Counter + +MEAN_PLACEMENT = 4.5 +MAX_ITEMS = 3 + + +def trait_counts(board: list[dict], static_units: dict) -> Counter: + """Trait -> Anzahl Units auf dem Board. + + Bewusst selbst gezählt statt aus Riots num_units gelesen: nur so rechnen + Training und Sim dieselbe Zahl. Der Unterschied kostet 0.004 Korrelation. + """ + counts: Counter = Counter() + for u in board: + unit = static_units.get(u["api_name"]) + if not unit: + continue + for trait in set(unit.get("traits") or []): + counts[trait] += 1 + return counts + + +def trait_tiers(counts: Counter, static_traits: dict) -> dict: + """Trait -> erreichte Breakpoint-Stufe (1-basiert), nur aktive.""" + out = {} + for trait, n in counts.items(): + info = static_traits.get(trait) + if not info: + continue + ordinal = 0 + for i, bp in enumerate(info["breakpoints"], start=1): + if n >= bp["min_units"]: + ordinal = i + if ordinal: + out[trait] = ordinal + return out + + +def _value(unit: dict, stars: int) -> int: + """Goldwert einer Unit: der klassische Boardwert aus TFT.""" + return unit["cost"] * 3 ** (stars - 1) + + +def features(board: list[dict], level: int, artifact: dict) -> list[float]: + """Merkmalsvektor eines Boards. Reihenfolge ist Teil des Modells.""" + static = artifact["static"] + units_tbl, traits_tbl = static["units"], static["traits"] + tables = (artifact.get("rating") or {}).get("tables") or {} + unit_q = tables.get("unit", {}) + star_q = tables.get("star", {}) + item_q = tables.get("item", {}) + unit_item_q = tables.get("unit_item", {}) + star_item_q = tables.get("star_item", {}) + trait_q = tables.get("trait", {}) + trait_count_q = tables.get("trait_count", {}) + signature_q = tables.get("signature", {}) + + known = [u for u in board if u["api_name"] in units_tbl] + n = max(len(known), 1) + vals = sorted((_value(units_tbl[u["api_name"]], u["stars"]) for u in known), + reverse=True) or [0] + costs = [units_tbl[u["api_name"]]["cost"] for u in known] or [0] + stars = [u["stars"] for u in known] + items = [i for u in known for i in u.get("items", [])] + carry = max(known, key=lambda u: _value(units_tbl[u["api_name"]], u["stars"])) \ + if known else None + + counts = trait_counts(known, units_tbl) + tiers = trait_tiers(counts, traits_tbl) + count_vals = list(counts.values()) or [0] + signature = max(counts.items(), key=lambda x: x[1]) if counts else None + + components = set(artifact.get("component_pool") or []) + crafted = set(artifact.get("item_pool") or []) + + q_unit = [unit_q.get(u["api_name"], 0.0) for u in known] or [0.0] + q_star = [star_q.get(f"{u['api_name']}|{u['stars']}", 0.0) for u in known] or [0.0] + q_si = [star_item_q.get( + f"{u['api_name']}|{u['stars']}|{min(len(u.get('items', [])), MAX_ITEMS)}", 0.0) + for u in known] or [0.0] + q_trait = [trait_q.get(f"{t}|{lvl}", 0.0) for t, lvl in tiers.items()] or [0.0] + q_tc = [trait_count_q.get(f"{t}|{c}", 0.0) for t, c in counts.items()] or [0.0] + + return [ + math.log1p(sum(vals)), vals[0], sum(vals[:3]), sum(vals[:5]), + len(known), level, level - len(known), + len(items), len(set(items)), len(items) / n, + sum(1 for i in items if i in components), + sum(1 for i in items if i in crafted), + sum(1 for i in items if i not in components and i not in crafted), + sum(1 for s in stars if s >= 2), sum(1 for s in stars if s >= 3), + sum(1 for s in stars if s >= 2) / n, + sum(1 for u in known if len(u.get("items", [])) >= MAX_ITEMS), + max(costs), sum(costs) / n, sum(1 for c in costs if c >= 4), + len(tiers), sum(tiers.values()), max(tiers.values(), default=0), + sum(1 for t in tiers.values() if t >= 3), + max(count_vals), sum(count_vals), sum(1 for c in count_vals if c >= 4), + sum(q_unit), max(q_unit), sum(q_unit) / n, + sum(q_star), max(q_star), sum(q_si), max(q_si), + sum(item_q.get(i, 0.0) for i in items), + sum(unit_item_q.get(f"{u['api_name']}|{i}", 0.0) + for u in known for i in u.get("items", [])), + sum(q_trait), max(q_trait), sum(q_tc), max(q_tc), + signature_q.get(f"{signature[0]}|{signature[1]}", 0.0) if signature else 0.0, + len(carry.get("items", [])) if carry else 0, + unit_q.get(carry["api_name"], 0.0) if carry else 0.0, + star_q.get(f"{carry['api_name']}|{carry['stars']}", 0.0) if carry else 0.0, + ] + + +def _walk(node, row: list[float]) -> float: + while isinstance(node, list): + column, threshold, left, right = node + node = left if row[column] <= threshold else right + return node + + +def predict_placement(board: list[dict], level: int, artifact: dict) -> float | None: + """Erwartete Platzierung, 1 bis 8. None, wenn kein Modell im Artefakt steckt.""" + model = artifact.get("rating") + if not model or not model.get("trees"): + return None + row = features(board, level, artifact) + value = model["base"] + model["lr"] * sum(_walk(t, row) for t in model["trees"]) + return min(max(value, 1.0), 8.0) + + +def board_rating(board: list[dict], level: int, artifact: dict) -> float | None: + """Wie predict_placement, nur größer = besser. Praktisch zum Sortieren.""" + place = predict_placement(board, level, artifact) + return None if place is None else MEAN_PLACEMENT - place diff --git a/backend/tft/model/score.py b/backend/tft/model/score.py deleted file mode 100644 index 9b442c4..0000000 --- a/backend/tft/model/score.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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 - - -def active_trait_tiers(board_units: list[dict], static_traits: dict, static_units: dict) -> dict: - """trait api_name -> reached breakpoint ordinal (1-based), only active traits.""" - counts: dict[str, int] = {} - for u in board_units: - unit = static_units.get(u["api_name"]) - if not unit: - continue - for trait in set(unit["traits"]): - counts[trait] = counts.get(trait, 0) + 1 - - tiers = {} - for trait, n in counts.items(): - info = static_traits.get(trait) - if not info: - continue - ordinal = 0 - for i, bp in enumerate(info["breakpoints"], start=1): - if n >= bp["min_units"]: - ordinal = i - if ordinal: - tiers[trait] = ordinal - return tiers - - -def unit_profile(u: dict, artifact: dict) -> dict | None: - """Kampfprofil einer Board-Unit: {"off", "def", "defensive", "unit"}. - - "defensive" steuert die Zielreihenfolge im Kampf: Frontline/Utility - sterben zuerst, Carries zuletzt. - """ - unit = artifact["static"]["units"].get(u["api_name"]) - if not unit: - 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 {"off": stats["dps"], "def": stats["ehp"], - "defensive": role in ("frontline", "utility"), "unit": u} - - -def board_profiles(board_units: list[dict], artifact: dict) -> list[dict]: - return [p for u in board_units if (p := unit_profile(u, artifact))] - - -def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float: - """sqrt(Σoff × Σdef). augments bleibt nur für Signatur-Kompatibilität.""" - profiles = board_profiles(board_units, artifact) - off = sum(p["off"] for p in profiles) - dfn = sum(p["def"] for p in profiles) - # /100: nur Anzeige-Skalierung, für Vergleiche irrelevant. - return (off * dfn) ** 0.5 / 100 diff --git a/backend/tft/model/statsheet.py b/backend/tft/model/statsheet.py deleted file mode 100644 index 427fdff..0000000 --- a/backend/tft/model/statsheet.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Effektive Kampfprofile aus exakten cdragon-Werten: eHP und DPS pro Unit. - -Konstanten spiegeln set17.toml [stars] bzw. das TFT-Mana-Modell. -Item-Wert-Konventionen laut Datenkatalog: AD = Fraction, AP = flat (Basis 100), -AS/CritChance = Prozentzahl, Health/Armor/MagicResist/ManaRegen = flat. -""" - -import re - -HP_STAR_MULT = 1.8 -AD_STAR_MULT = 1.5 -MANA_PER_ATTACK = 10 -FRONTLINE_MANA_PER_SEC = 10 -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", - "MagicResist", "ManaRegen", "BonusDamage", "DamageAmp", - "BonusPercentHP", "CritDamageToGive") - -_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. -_SKIP_TOKENS = {"duration", "threshold", "rounds", "per", "num", "gold", - "tooltiponly", "mana", "seconds", "delay", "range", "radius"} - - -def _empty_acc() -> dict: - 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, - "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: - for api in item_apis: - effects = (static_items.get(api) or {}).get("effects") or {} - for key, val in effects.items(): - if val is None: - continue - if key == "AD": - acc["ad_pct"] += val - elif key == "AP": - acc["ap_flat"] += val - elif key == "AS": - acc["as_pct"] += val / 100 - elif key == "CritChance": - acc["crit_chance"] += val / 100 - elif key == "Health": - acc["hp_flat"] += val - elif key == "Armor": - acc["armor_flat"] += val - elif key == "MagicResist": - acc["mr_flat"] += val - elif key == "ManaRegen": - 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 - - -def item_is_modeled(item_info: dict) -> bool: - effects = (item_info or {}).get("effects") or {} - return any(effects.get(k) is not None for k in MAPPED_ITEM_KEYS) - - -def _fraction(value: float) -> float: - """cdragon mischt Fraction (0.15) und Prozentzahl (15.0) — normalisieren.""" - return value if abs(value) <= 1.0 else value / 100 - - -def trait_buffs(active_tiers: dict, static_traits: dict) -> tuple[dict, set]: - """Erkannte Trait-Variablen -> teamweite Stat-Buffs; Rest bleibt generisch.""" - buffs = _empty_acc() - recognized: set[str] = set() - for trait, ordinal in active_tiers.items(): - info = static_traits.get(trait) - if not info: - continue - breakpoints = info.get("breakpoints", []) - if ordinal - 1 >= len(breakpoints): - continue - variables = breakpoints[ordinal - 1].get("variables") or {} - matched = False - for name, value in variables.items(): - if value is None or not isinstance(value, (int, float)): - continue - tokens = {t.lower() for t in _TOKEN_RE.findall(name)} - if tokens & _SKIP_TOKENS or name.startswith("{"): - continue - f = _fraction(value) - if "adap" in tokens: - buffs["ad_pct"] += f - buffs["ap_flat"] += f * 100 - elif "ad" in tokens: - buffs["ad_pct"] += f - elif "ap" in tokens: - buffs["ap_flat"] += f * 100 - elif "as" in tokens or "attackspeed" in tokens: - buffs["as_pct"] += f - elif "armor" in tokens: - buffs["armor_flat"] += value if abs(value) > 1 else value * 100 - elif "mr" in tokens or "magicresist" in tokens or "resist" in tokens: - buffs["mr_flat"] += value if abs(value) > 1 else value * 100 - elif "health" in tokens or "hp" in tokens: - buffs["hp_pct"] += f - elif "shield" in tokens or "durability" in tokens or "dr" in tokens \ - or ("damage" in tokens and "reduction" in tokens): - buffs["dr"] += f - elif "damageamp" in tokens or ("damage" in tokens and "amp" in tokens) \ - or "bonusdamage" in tokens: - buffs["damage_amp"] += f - elif "heal" in tokens or "omnivamp" in tokens: - buffs["dr"] += f * 0.5 - else: - continue - matched = True - if matched: - recognized.add(trait) - return buffs, recognized - - -def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict, - team_buffs: dict | None, role: str | None, - spell_cap: float | None = None) -> dict: - stats = unit["stats"] - hp = (stats.get("hp") or 0) * HP_STAR_MULT ** (stars - 1) - ad = (stats.get("damage") or 0) * AD_STAR_MULT ** (stars - 1) - armor = stats.get("armor") or 0 - mr = stats.get("magicResist") or 0 - aspd = stats.get("attackSpeed") or 0 - crit = stats.get("critChance") or 0.25 - crit_mult = stats.get("critMultiplier") or 1.4 - mana_gap = max((stats.get("mana") or 0) - (stats.get("initialMana") or 0), 1) - - acc = _empty_acc() - for key, val in (team_buffs or {}).items(): - acc[key] += val - _apply_item_effects(item_apis, static_items, acc) - - hp = (hp + acc["hp_flat"]) * (1 + acc["hp_pct"]) - armor += acc["armor_flat"] - mr += acc["mr_flat"] - ehp = hp * (1 + (armor + mr) / 200) * (1 + acc["dr"]) - - as_eff = aspd * (1 + acc["as_pct"]) - 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)) - - spell_dps = 0.0 - array = unit.get("spell_damage") - if array: - dmg = array[min(stars, len(array) - 1)] or 0 - scaling = unit.get("spell_scaling") - if scaling in ("ap", "both"): - dmg *= 1 + acc["ap_flat"] / 100 - if scaling in ("ad", "both"): - dmg *= 1 + acc["ad_pct"] - if unit.get("spell_on_attack"): - # Passive: Bonus-Schaden pro Auto-Attacke, kein Mana-Zyklus. - spell_dps = dmg * as_eff - else: - frontline = FRONTLINE_MANA_PER_SEC if role == "frontline" else 0 - cast_rate = min( - (as_eff * MANA_PER_ATTACK + frontline + acc["mana_regen"]) / mana_gap, - CAST_RATE_CAP, - ) - spell_dps = dmg * cast_rate - # Ausreißer-Guard: Spell-Rohwerte sind zwischen Champions nicht - # vergleichbar (per-Hit vs. total). Relativer Deckel: max. 3× eigene - # Auto-DPS; das Populations-Perzentil dient als Floor für AD-lose Caster. - 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"]), - "spell_dps": spell_dps, - } diff --git a/backend/tft/model/train_rating.py b/backend/tft/model/train_rating.py new file mode 100644 index 0000000..842cdc9 --- /dev/null +++ b/backend/tft/model/train_rating.py @@ -0,0 +1,195 @@ +"""Training der Boardbewertung: gelernte Tabellen + Gradient Boosting. + +Läuft nur beim Bauen des Artefakts und braucht dafür numpy. Die Auswertung +in rating.py kommt ohne aus — das Ergebnis ist reines JSON. +""" + +import json +from collections import defaultdict + +from tft.model import rating +from tft.model.baseline import unit_aliases + +# Schrumpfung gegen den Mittelwert. 30 für grobe Schlüssel, 20 für feine — +# auf dem dev-Satz gemessen, der Unterschied zu 10 oder 250 liegt bei 0.002. +PSEUDO_COARSE = 30 +PSEUDO_FINE = 20 + +TREES = 300 +DEPTH = 4 +LEARNING_RATE = 0.06 +BINS = 32 +MIN_LEAF = 40 +SUBSAMPLE = 0.7 + + +def _shrunk(placements: list[int], pseudo: int) -> float: + """Mittlere Platzierung, gegen 4.5 gezogen. Größer = besser.""" + total = sum(placements) + rating.MEAN_PLACEMENT * pseudo + return rating.MEAN_PLACEMENT - total / (len(placements) + pseudo) + + +def _table(boards: list[dict], key_fn, pseudo: int) -> dict: + acc = defaultdict(list) + for b in boards: + for key in key_fn(b): + acc[key].append(b["place"]) + return {k: round(_shrunk(v, pseudo), 5) for k, v in acc.items()} + + +def learn_tables(boards: list[dict], static_units: dict) -> dict: + """Alle gelernten Nachschlagetabellen. Schlüssel sind Strings — JSON-fähig.""" + def units(b): + return {u["api_name"] for u in b["units"]} + + def star(b): + return {f"{u['api_name']}|{u['stars']}" for u in b["units"]} + + def item(b): + return {i for u in b["units"] for i in u["items"]} + + def unit_item(b): + return {f"{u['api_name']}|{i}" for u in b["units"] for i in u["items"]} + + def star_item(b): + return {f"{u['api_name']}|{u['stars']}|{min(len(u['items']), rating.MAX_ITEMS)}" + for u in b["units"]} + + def trait(b): + return {f"{t}|{lvl}" for t, lvl in b["tiers"].items()} + + def trait_count(b): + return {f"{t}|{c}" for t, c in b["counts"].items()} + + def signature(b): + if not b["counts"]: + return set() + name, count = max(b["counts"].items(), key=lambda x: x[1]) + return {f"{name}|{count}"} + + return { + "unit": _table(boards, units, PSEUDO_COARSE), + "star": _table(boards, star, PSEUDO_COARSE), + "item": _table(boards, item, PSEUDO_COARSE), + "trait": _table(boards, trait, PSEUDO_COARSE), + "unit_item": _table(boards, unit_item, PSEUDO_FINE), + "star_item": _table(boards, star_item, PSEUDO_FINE), + "trait_count": _table(boards, trait_count, PSEUDO_FINE), + "signature": _table(boards, signature, PSEUDO_FINE), + } + + +def boards_from_db(conn, set_number: int, static_units: dict, + exclude_holdout: bool = True) -> list[dict]: + """Endboards in der Form, die rating.features erwartet. + + Der Holdout bleibt draußen — dieselben Matches wie in learn.py, damit + `calibrate` ehrlich auf ungesehenen Boards misst. + """ + alias = unit_aliases(static_units) + where = "WHERE set_number = ?" + if exclude_holdout: + where += " AND substr(match_id, -1) NOT IN ('0', '5')" + rows = conn.execute( + f"SELECT placement, level, units FROM endboards {where}", + (set_number,), + ).fetchall() + out = [] + for place, level, units_json in rows: + units = [{"api_name": alias.get(u["character_id"], u["character_id"]), + "stars": u["tier"], "items": u.get("itemNames", [])} + for u in json.loads(units_json)] + known = [u for u in units if u["api_name"] in static_units] + counts = rating.trait_counts(known, static_units) + out.append({"place": place, "level": level, "units": known, + "counts": dict(counts), "tiers": {}}) + return out + + +def train(conn, set_number: int, artifact: dict) -> dict: + """Gibt das fertige Modell zurück: Tabellen + Bäume. Leer ohne numpy.""" + try: + import numpy as np + except ImportError: # pragma: no cover - nur ohne numpy erreichbar + return {} + + static_units = artifact["static"]["units"] + boards = boards_from_db(conn, set_number, static_units) + if len(boards) < 500: + return {} + + tables = learn_tables(boards, static_units) + for b in boards: + b["tiers"] = rating.trait_tiers(b["counts"], artifact["static"]["traits"]) + tables = learn_tables(boards, static_units) # Trait-Stufen jetzt gefüllt + + scored = {**artifact, "rating": {"tables": tables}} + X = np.array([rating.features(b["units"], b["level"], scored) for b in boards]) + y = np.array([float(b["place"]) for b in boards]) + + trees, base = _fit(np, X, y) + return {"tables": tables, "trees": trees, "base": base, + "lr": LEARNING_RATE, "boards": len(boards)} + + +def _fit(np, X, y): + """Gradient Boosting auf Histogramm-Bins; gibt JSON-fähige Bäume zurück.""" + edges = [np.quantile(X[:, j], np.linspace(0, 1, BINS + 1)[1:-1]) + for j in range(X.shape[1])] + B = np.stack([np.searchsorted(edges[j], X[:, j]).astype(np.int16) + for j in range(X.shape[1])], axis=1) + rng = np.random.default_rng(1) + base = float(y.mean()) + pred = np.full(len(y), base) + n_sub = int(len(y) * SUBSAMPLE) + trees = [] + + def grow(g, rows, depth): + if depth == 0 or len(rows) < 2 * MIN_LEAF: + return round(float(g[rows].mean()), 6) + best = None + parent, n = g[rows].sum(), len(rows) + for j in range(B.shape[1]): + col = B[rows, j] + cnt = np.bincount(col, minlength=BINS) + tot = np.bincount(col, weights=g[rows], minlength=BINS) + cl, sl = np.cumsum(cnt), np.cumsum(tot) + cr, sr = n - cl, parent - sl + ok = (cl >= MIN_LEAF) & (cr >= MIN_LEAF) + if not ok.any(): + continue + gain = np.where(ok, sl ** 2 / np.maximum(cl, 1) + sr ** 2 / np.maximum(cr, 1), + -np.inf) + t = int(np.argmax(gain)) + if best is None or gain[t] > best[0]: + best = (gain[t], j, t) + if best is None: + return round(float(g[rows].mean()), 6) + _, j, t = best + mask = B[rows, j] <= t + # Schwelle als echter Wert, damit die Auswertung ohne Bins auskommt. + threshold = float(edges[j][t]) if t < len(edges[j]) else float(X[:, j].max()) + return [j, threshold, grow(g, rows[mask], depth - 1), + grow(g, rows[~mask], depth - 1)] + + def apply(node, rows_all): + out = np.zeros(len(rows_all)) + stack = [(node, np.arange(len(rows_all)))] + while stack: + nd, rows = stack.pop() + if not isinstance(nd, list): + out[rows] = nd + continue + j, thr, left, right = nd + m = X[rows, j] <= thr + stack.append((left, rows[m])) + stack.append((right, rows[~m])) + return out + + for _ in range(TREES): + g = y - pred + rows = rng.choice(len(y), n_sub, replace=False) + tree = grow(g, rows, DEPTH) + trees.append(tree) + pred += LEARNING_RATE * apply(tree, y) + return trees, base diff --git a/backend/tft/sim/arena.py b/backend/tft/sim/arena.py new file mode 100644 index 0000000..d48e217 --- /dev/null +++ b/backend/tft/sim/arena.py @@ -0,0 +1,134 @@ +"""Zwei Boards gegeneinander — die Brücke zwischen Spiel und Kampf-Engine. + +`game.py` kennt nur Board-Listen. Die Engine will Kämpfer mit Position, +Items und Trait-Boni. Diese Umrechnung steht hier, damit weder das Spiel +noch die Engine die andere Seite kennen muss. +""" + +import random +from dataclasses import dataclass + +from tft.sim import battle, hexgrid, lineup, traits + +# Die eigene Hälfte: vier Reihen, sieben Spalten. +BOARD_ROWS = hexgrid.HALF +BOARD_COLS = hexgrid.COLS + + +@dataclass +class FightResult: + """Was das Spiel vom Kampf braucht: Sieger und Überlebende.""" + winner: str | None # "a" | "b" | None = Unentschieden + frames: int + survivors_a: list[dict] + survivors_b: list[dict] + + +def player_damage(stage: int, survivors: list[dict], cfg: dict) -> int: + """Schaden am Verlierer: Grundwert der Stage plus überlebende Units.""" + d = cfg["damage"] + base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)] + return base + d["per_surviving_unit"] * len(survivors) + + +def from_hex(hexid: int, which: str) -> dict: + """Feldnummer des Spielers -> Koordinaten auf dem Kampffeld. + + Der Spieler nummeriert seine vier Reihen von vorne nach hinten, das + Kampffeld zählt über beide Hälften durch. Reihe 0 des Spielers ist + also die vorderste — für Seite a Reihe 3, für Seite b Reihe 4. + """ + row, col = divmod(int(hexid), BOARD_COLS) + row = min(row, BOARD_ROWS - 1) + return {"col": col, + "row": (BOARD_ROWS - 1 - row) if which == "a" else (BOARD_ROWS + row)} + + +def placed(board: list[dict], which: str, units: dict) -> list[dict]: + """Eigene Positionen benutzen, wo es welche gibt. + + Boards aus der Riot-API haben keine — dort greift die Standard- + Aufstellung aus `lineup`. + """ + if not any(entry.get("hex") is not None for entry in board): + return lineup.place(board, which, units) + out, taken = [], set() + fallback = lineup.place(board, which, units) + for entry, spare in zip(board, fallback): + hexid = entry.get("hex") + cell = from_hex(hexid, which) if hexid is not None else \ + {"col": spare["col"], "row": spare["row"]} + if (cell["col"], cell["row"]) in taken: + cell = {"col": spare["col"], "row": spare["row"]} + taken.add((cell["col"], cell["row"])) + out.append({**entry, **cell}) + return out + + +def side(board: list[dict], which: str, artifact: dict) -> list: + """Board -> aufgestellte Kämpfer mit Items und Trait-Boni.""" + static = artifact["static"] + units, items, trait_data = static["units"], static["items"], static["traits"] + fighters = [] + for entry in placed(board, which, units): + one = battle.to_fighter(entry, which, units) + if one is None: + continue + battle.apply_items(one, entry.get("items") or [], items) + fighters.append(one) + traits.apply(fighters, board, units, trait_data) + return fighters + + +def fight(board_a: list[dict], board_b: list[dict], artifact: dict, + rng: random.Random, record: bool = False) -> tuple: + """(Ergebnis, Protokoll). Das Protokoll ist leer ohne `record`. + + Leere Boards verlieren sofort — sonst liefe die Engine 30 Sekunden + gegen niemanden. + """ + team_a, team_b = side(board_a, "a", artifact), side(board_b, "b", artifact) + if not team_a or not team_b: + winner = "a" if team_a else "b" if team_b else None + return FightResult(winner=winner, frames=0, + survivors_a=board_a if winner == "a" else [], + survivors_b=board_b if winner == "b" else []), None + res = battle.fight(team_a, team_b, rng, record=record) + out = FightResult(winner=res.winner, + frames=int(res.seconds * battle.TPS), + survivors_a=res.survivors_a, + survivors_b=res.survivors_b) + return out, (_log(res, artifact) if record else None) + + +def strength(board: list[dict], artifact: dict) -> dict: + """Angriff und Widerstand eines Boards, aus derselben Engine wie der Kampf. + + Vorher kam diese Zahl aus einem eigenen Bewertungsmodell. Das konnte + dem Kampf widersprechen: ein Board mit hohem "off" verlor trotzdem. + Hier steckt genau das drin, womit auch gekämpft wird — Items und + Trait-Boni eingerechnet. + + Skala: Tausend Punkte, damit die Zahlen zweistellig bleiben. + """ + fighters = side(board, "a", artifact) + return { + "off": round(sum(battle.dps(f) for f in fighters) / 100.0, 1), + "def": round(sum(battle.toughness(f) for f in fighters) / 1000.0, 1), + } + + +def _log(res, artifact: dict) -> dict: + names = artifact["static"]["units"] + return { + "cols": hexgrid.COLS, + "rows": hexgrid.ROWS, + "tps": battle.TPS, + "winner": res.winner, + "seconds": res.seconds, + "units": [{"slot": f.slot, "side": f.side, + "name": names.get(f.key, {}).get("name", f.key), + "max_hp": round(f.max_hp)} + for f in res.fighters], + "events": res.events, + } diff --git a/backend/tft/sim/battle.py b/backend/tft/sim/battle.py new file mode 100644 index 0000000..51cf5cd --- /dev/null +++ b/backend/tft/sim/battle.py @@ -0,0 +1,450 @@ +"""Kampf auf dem Hex-Feld, Tick für Tick. + +Der alte `combat.py` rechnete mit einer Gesamt-Offense, die die Gegner +nacheinander abarbeitet. Das ignoriert Reichweite, Position und +Fähigkeiten — für die Frage "wie stark ist diese Unit" also unbrauchbar. + +Hier laufen die Units einzeln: sie suchen ein Ziel, gehen darauf zu, +schlagen zu, sammeln Mana und zaubern. Alles deterministisch bei +gleichem Seed. + +Was aus den Statikdaten kommt und damit belegt ist: + hp, armor, magicResist, damage (je Sternstufe), attackSpeed, range, + mana, initialMana, critChance, critMultiplier, + spell_damage (je Sternstufe), spell_damage_type, spell_scaling. + +Was NICHT in den Daten steht und hier als Annahme gesetzt ist — jeweils +als Konstante mit Namen, damit man sie prüfen und drehen kann: + * Mana pro Angriff und pro erlittenem Schaden + * Schadensminderung aus Rüstung + * Zielwahl und Bewegung +Diese Annahmen sind der Grund, warum die Engine gegen echte Boards +geprüft werden muss, nicht gegen ihr eigenes Ergebnis. + +Fähigkeiten wirken hier generisch: beim Zaubern Schaden in Höhe von +`spell_damage` auf das aktuelle Ziel, gemindert nach Schadensart. Die +63 Einzelwirkungen (Flächen, Buffs, Schilde) fehlen noch. +""" + +import random +from dataclasses import dataclass, field + +from tft.sim import hexgrid as H + +# Auflösung der Simulation. 30 Ticks je Sekunde reichen: der schnellste +# Angriff im Set liegt bei rund 5 pro Sekunde. +TPS = 30 +MAX_SECONDS = 30.0 + +# --- Annahmen, nicht aus den Daten belegt --- +# Rüstung und Magieresistenz mindern nach derselben Formel. +MITIGATION_BASE = 100.0 +# Mana je Angriff. +MANA_PER_ATTACK = 10.0 +# Anteil des erlittenen Schadens, der als Mana gutgeschrieben wird. +MANA_PER_DAMAGE = 0.01 +MANA_PER_HIT_CAP = 42.5 +# Units ohne Mana-Kosten zaubern nie. +MIN_SPELL_MANA = 1.0 +# Grundwert der Fähigkeitsstärke. Items geben Punkte darauf, 55 AP heißt +# also Faktor 1.55 auf den Fähigkeitsschaden. +BASE_AP = 100.0 +# Mana-Regeneration wirkt je Sekunde. +MANA_REGEN_PER_SECOND = 1.0 + + +# Was die Item-Effekte bedeuten. Aus den Statikdaten abgelesen, nicht +# geraten: AD und DamageAmp sind Anteile, AP und AS Punkte, Health, +# Armor und MagicResist flache Werte. +ITEM_FRACTIONS = ("AD", "DamageAmp") +ITEM_POINTS = ("AP", "AS", "CritChance", "ManaRegen") +ITEM_FLAT = {"Health": "max_hp", "Armor": "armor", "MagicResist": "magic_resist"} +# AD und Angriffstempo addieren sich, statt sich zu multiplizieren. +ADDITIVE_ITEMS = True + +# Flächenschaden: manche Fähigkeiten nennen in ihren Variablen einen +# Radius in Feldern. Der Name ist je Champion anders, das Muster nicht — +# "HexRadius", "AoEHexRange", "DamageHexRange". Fehlt so eine Variable, +# trifft die Fähigkeit nur das Ziel. +import re as _re +AOE_RADIUS_RE = _re.compile(r"(hexradius|hexrange|aoehex|hexrangeaoe)", _re.I) +AOE_COUNT_RE = _re.compile(r"(numenemieshit|numsecondarytargets|numenemiestofireat)", + _re.I) +# Größer als das halbe Feld ergibt keinen Sinn. +MAX_AOE_RADIUS = 4 +# Der Fähigkeitsschaden aus den Daten ist der Gesamtschaden eines Zaubers. +# Trifft er mehrere, wird er geteilt. Volle Wucht auf jedes Ziel war +# gemessen deutlich schlechter: 68,2 % gegen 74,3 % ohne Fläche. +SPLIT_AREA_DAMAGE = True +# Ziel behalten oder in jedem Tick das nächste nehmen? Gemessen wird das +# unten in der Kampfschleife; die Konstante steht hier, damit man beide +# Varianten vergleichen kann. +KEEP_TARGET = True + + +@dataclass +class Fighter: + """Eine Unit im Kampf. Nur Zahlen — kein Bezug zurück ins Artefakt.""" + key: str + side: str + col: int + row: int + hp: float + max_hp: float + armor: float + magic_resist: float + attack_damage: float + attack_speed: float + attack_range: int + crit_chance: float + crit_multiplier: float + mana: float + max_mana: float + spell_damage: float + spell_magic: bool + spell_radius: int = 0 + spell_targets: int = 1 + unit: dict = field(default_factory=dict) + base_attack_damage: float = 0.0 + target: object = None + slot: int = -1 + logged_death: bool = False + ability_power: float = BASE_AP + damage_amp: float = 0.0 + mana_regen: float = 0.0 + durability: float = 0.0 + cooldown: float = 0.0 + casts: int = 0 + damage_done: float = 0.0 + + @property + def alive(self) -> bool: + return self.hp > 0 + + @property + def pos(self) -> tuple: + return (self.col, self.row) + + +@dataclass +class BattleResult: + winner: str | None + seconds: float + survivors_a: list[dict] + survivors_b: list[dict] + fighters: list[Fighter] + # Ereignisse für die Anzeige. Leer, wenn niemand mitschreibt. + events: list[dict] = field(default_factory=list) + + +def dps(one: Fighter) -> float: + """Schaden je Sekunde, wie ihn die Engine austeilt. + + Kritische Treffer gehen als Erwartungswert ein, Schadensverstärkung + multiplikativ. Fähigkeiten fehlen — sie hängen am Mana-Fluss und + lassen sich nicht als Dauerwert angeben. + """ + crit = 1.0 + one.crit_chance * (one.crit_multiplier - 1.0) + return one.attack_damage * one.attack_speed * crit * (1.0 + one.damage_amp) + + +def toughness(one: Fighter) -> float: + """Effektives Leben: wie viel roher Schaden nötig ist. + + Rüstung und Magieresistenz werden gemittelt, weil beide Schadensarten + vorkommen. Härte aus Traits wirkt darüber hinaus. + """ + resist = (one.armor + one.magic_resist) / 2.0 + factor = (MITIGATION_BASE + max(resist, 0.0)) / MITIGATION_BASE + return one.max_hp * factor / max(1.0 - one.durability, 0.05) + + +def mitigate(damage: float, resist: float) -> float: + """Schadensminderung. 100 Rüstung halbiert den Schaden.""" + return damage * MITIGATION_BASE / (MITIGATION_BASE + max(resist, 0.0)) + + +def _stat(stats: dict, key: str, default: float = 0.0) -> float: + value = stats.get(key) + return float(value) if value is not None else default + + +def spell_shape(unit: dict, stars: int) -> tuple: + """(Radius, Zusatzziele) aus den Fähigkeits-Variablen. + + Die Namen sind je Champion anders, das Muster nicht. Ohne Treffer + bleibt es beim Einzelziel. + """ + radius = 0 + extra = 1 + for name, values in (unit.get("ability_vars") or {}).items(): + if not isinstance(values, list): + continue + value = _by_star(values, stars) + if AOE_RADIUS_RE.search(name): + radius = max(radius, min(int(value), MAX_AOE_RADIUS)) + elif AOE_COUNT_RE.search(name): + extra = max(extra, int(value)) + return radius, extra + + +def _by_star(values, stars: int, fallback: float = 0.0) -> float: + """Wert für eine Sternstufe. Riots Listen sind 1-basiert mit Leerplatz 0.""" + if not values: + return fallback + index = min(stars, len(values) - 1) + return float(values[index] or fallback) + + +# Leben je Sternstufe. Steht nicht in den Statikdaten — dort gibt es nur +# `damageByStar`. 1.8 ist der bekannte Wert aus dem Spiel. +# +# Höhere Werte heben die Trefferquote gegen echte Boards deutlich: 3.0 +# kommt auf 85 % statt 76 %. Das ist aber kein besserer Kampf, sondern +# eine Abkürzung. Auf Paaren mit GLEICHER Sternverteilung ändert der +# Faktor fast nichts (65 % bei 1.5, 68 % bei 3.0, Fehlerbalken ±3). +# Der Gewinn kommt allein daher, dass Boards mit mehr 3★-Units besser +# platzieren — das würde die Engine nur nachplappern. +STAR_HP = 1.8 + + +def to_fighter(entry: dict, side: str, static_units: dict) -> Fighter | None: + """Board-Eintrag -> Kämpfer. None, wenn die Unit unbekannt ist. + + entry: {"api_name", "stars", "col", "row"}. Fehlt die Position, wird + sie vom Aufrufer gesetzt. + """ + unit = static_units.get(entry.get("api_name")) + if not unit: + return None + stats = unit.get("stats") or {} + stars = max(1, min(int(entry.get("stars", 1)), 3)) + hp = _stat(stats, "hp") * (STAR_HP ** (stars - 1)) + damage = _by_star(stats.get("damageByStar"), stars, _stat(stats, "damage")) + return Fighter( + key=entry["api_name"], + side=side, + col=int(entry.get("col", 0)), + row=int(entry.get("row", 0)), + hp=hp, + max_hp=hp, + armor=_stat(stats, "armor"), + magic_resist=_stat(stats, "magicResist"), + attack_damage=damage, + attack_speed=_stat(stats, "attackSpeed", 0.6), + attack_range=int(_stat(stats, "range", 1)), + crit_chance=_stat(stats, "critChance", 0.25), + crit_multiplier=_stat(stats, "critMultiplier", 1.4), + mana=_stat(stats, "initialMana"), + max_mana=_stat(stats, "mana"), + spell_damage=_by_star(unit.get("spell_damage"), stars), + spell_magic=(unit.get("spell_damage_type") or "magic") != "physical", + spell_radius=spell_shape(unit, stars)[0], + spell_targets=spell_shape(unit, stars)[1], + base_attack_damage=damage, + unit=entry, + ) + + +def apply_items(one: Fighter, items: list[str], static_items: dict, + additive: bool = ADDITIVE_ITEMS) -> None: + """Item-Effekte auf die Werte eines Kämpfers rechnen. + + Mehrfach getragene Items zählen mehrfach — genau wie im Spiel. + + AD und Angriffstempo werden erst aufsummiert und dann einmal + angewandt. Multiplikativ gestapelt machten drei AD-Items aus 100 % + Angriffsschaden 320 statt 245 Prozent; damit gewann jedes Board mit + einem vollen Carry, und die Trefferquote fiel um zwei Punkte. + """ + add_ad = add_as = 0.0 + for key in items: + effects = (static_items.get(key) or {}).get("effects") or {} + for name, value in effects.items(): + if name == "AD": + add_ad += value + if not additive: + one.attack_damage *= 1.0 + value + elif name == "AS": + add_as += value / 100.0 + if not additive: + one.attack_speed *= 1.0 + value / 100.0 + elif name == "AP": + one.ability_power += value + elif name == "CritChance": + one.crit_chance += value / 100.0 + elif name == "DamageAmp": + one.damage_amp += value + elif name == "ManaRegen": + one.mana_regen += value + elif name in ITEM_FLAT: + field_name = ITEM_FLAT[name] + setattr(one, field_name, getattr(one, field_name) + value) + if additive: + one.attack_damage *= 1.0 + add_ad + one.attack_speed *= 1.0 + add_as + one.hp = one.max_hp + + +def _nearest(one: Fighter, foes: list[Fighter]) -> Fighter | None: + """Nächster lebender Gegner. Gleichstand entscheidet die Feldnummer. + + Ohne den festen Tiebreak hinge der Kampf an der Listenreihenfolge. + """ + best, best_key = None, None + for foe in foes: + if not foe.alive: + continue + key = (H.distance(one.pos, foe.pos), foe.row, foe.col) + if best_key is None or key < best_key: + best, best_key = foe, key + return best + + +def _step_towards(one: Fighter, target: Fighter, taken: set) -> None: + """Ein Feld näher. Belegte Felder werden umgangen.""" + now = H.distance(one.pos, target.pos) + best, best_key = None, None + for cell in H.neighbours(one.col, one.row): + if cell in taken: + continue + key = (H.distance(cell, target.pos), cell[1], cell[0]) + if key[0] < now and (best_key is None or key < best_key): + best, best_key = cell, key + if best: + taken.discard(one.pos) + taken.add(best) + one.col, one.row = best + + +def _log(events: list, tick: int, kind: str, actor: Fighter, + target: Fighter, everyone: list) -> None: + """Ein Ereignis festhalten. Tote Ziele bekommen einen eigenen Eintrag.""" + events.append({"t": tick, "e": kind, "u": actor.slot, "to": target.slot, + "hp": round(max(target.hp, 0))}) + for one in everyone: + if not one.alive and not getattr(one, "logged_death", False): + one.logged_death = True + events.append({"t": tick, "e": "die", "u": one.slot}) + + +def _gain_mana(one: Fighter, amount: float) -> None: + if one.max_mana >= MIN_SPELL_MANA: + one.mana = min(one.mana + amount, one.max_mana) + + +def _hit(attacker: Fighter, target: Fighter, rng: random.Random) -> None: + raw = attacker.attack_damage * (1.0 + attacker.damage_amp) + if rng.random() < attacker.crit_chance: + raw *= attacker.crit_multiplier + dealt = mitigate(raw, target.armor) * (1.0 - target.durability) + target.hp -= dealt + attacker.damage_done += dealt + _gain_mana(attacker, MANA_PER_ATTACK) + _gain_mana(target, min(raw * MANA_PER_DAMAGE, MANA_PER_HIT_CAP)) + + +def _cast(caster: Fighter, target: Fighter, foes: list[Fighter]) -> None: + """Fähigkeit auf das Ziel und, wenn sie eine Fläche hat, auf dessen Umfeld.""" + # AP-Fähigkeiten skalieren mit der Fähigkeitsstärke, AD-Fähigkeiten + # mit dem Angriffsschaden. `spell_damage` gilt für die nackte Unit, + # ein Carry mit drei Items schlägt also entsprechend härter zu. + if caster.spell_magic: + scale = caster.ability_power / BASE_AP + else: + base = caster.base_attack_damage or 1.0 + scale = caster.attack_damage / base + raw = caster.spell_damage * scale * (1.0 + caster.damage_amp) + + hit = [target] + if caster.spell_radius or caster.spell_targets > 1: + others = [f for f in foes + if f.alive and f is not target + and H.distance(target.pos, f.pos) <= max(caster.spell_radius, 1)] + others.sort(key=lambda f: (H.distance(target.pos, f.pos), f.row, f.col)) + hit += others[:max(caster.spell_targets - 1, len(others) + if caster.spell_radius else 0)] + + share = raw / len(hit) if SPLIT_AREA_DAMAGE else raw + for one in hit: + resist = one.magic_resist if caster.spell_magic else one.armor + dealt = mitigate(share, resist) * (1.0 - one.durability) + one.hp -= dealt + caster.damage_done += dealt + caster.casts += 1 + caster.mana = 0.0 + + +def fight(team_a: list[Fighter], team_b: list[Fighter], rng: random.Random, + max_seconds: float = MAX_SECONDS, record: bool = False) -> BattleResult: + """Beide Seiten ticken gleichzeitig. Gleicher Seed, gleicher Ausgang. + + Mit `record` wird ein Ereignis-Protokoll mitgeschrieben: wer sich wohin + bewegt, wer wen trifft, wer stirbt. Das Frontend spielt es ab. Ohne + den Schalter kostet es nichts — die Auswertung läuft millionenfach. + """ + everyone = team_a + team_b + taken = {f.pos for f in everyone} + dt = 1.0 / TPS + ticks = int(max_seconds * TPS) + elapsed = 0.0 + events: list[dict] = [] + if record: + for i, one in enumerate(everyone): + one.slot = i + events.append({"t": 0, "e": "spawn", "u": i, "side": one.side, + "name": one.key, "col": one.col, "row": one.row, + "hp": round(one.hp)}) + + for tick in range(ticks): + if not any(f.alive for f in team_a) or not any(f.alive for f in team_b): + break + elapsed += dt + for one in everyone: + if not one.alive: + continue + foes = team_b if one.side == "a" else team_a + # Ziel behalten, solange es lebt und in Reichweite ist. Sonst + # neu suchen. Nur "solange es lebt" war zu wenig: Units liefen + # dann einem weit entfernten Ziel hinterher, statt den Gegner + # vor der Nase zu schlagen — die Trefferquote fiel um zehn Punkte. + target = one.target + if (not KEEP_TARGET or target is None or not target.alive + or H.distance(one.pos, target.pos) > one.attack_range): + target = one.target = _nearest(one, foes) + if target is None: + continue + if H.distance(one.pos, target.pos) > one.attack_range: + before = one.pos + _step_towards(one, target, taken) + if record and one.pos != before: + events.append({"t": tick, "e": "move", "u": one.slot, + "col": one.col, "row": one.row}) + continue + # Zaubern hat Vorrang vor dem nächsten Angriff. + if one.max_mana >= MIN_SPELL_MANA and one.mana >= one.max_mana: + _cast(one, target, foes) + if record: + _log(events, tick, "cast", one, target, everyone) + continue + _gain_mana(one, one.mana_regen * MANA_REGEN_PER_SECOND * dt) + one.cooldown -= dt + if one.cooldown <= 0.0: + _hit(one, target, rng) + if record: + _log(events, tick, "hit", one, target, everyone) + one.cooldown = 1.0 / max(one.attack_speed, 0.1) + + alive_a = [f for f in team_a if f.alive] + alive_b = [f for f in team_b if f.alive] + if alive_a and alive_b: + # Zeit abgelaufen: mehr verbleibendes Leben gewinnt. + rest_a = sum(f.hp for f in alive_a) + rest_b = sum(f.hp for f in alive_b) + winner = "a" if rest_a > rest_b else "b" if rest_b > rest_a else None + else: + winner = "a" if alive_a else "b" if alive_b else None + return BattleResult(winner=winner, seconds=round(elapsed, 3), + survivors_a=[f.unit for f in alive_a], + survivors_b=[f.unit for f in alive_b], + fighters=everyone, events=events) diff --git a/backend/tft/sim/combat.py b/backend/tft/sim/combat.py deleted file mode 100644 index 3436cee..0000000 --- a/backend/tft/sim/combat.py +++ /dev/null @@ -1,107 +0,0 @@ -"""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 random -from dataclasses import dataclass - -FPS = 60 -EPS = 1e-9 - - -@dataclass -class FightResult: - winner: str | None # "a" | "b" | None = Unentschieden - frames: int - survivors_a: list[dict] - survivors_b: list[dict] - - -def _queue(team: list[dict], rng: random.Random) -> list[dict]: - """Sterbereihenfolge: defensive Units zuerst, innerhalb der Gruppe zufällig.""" - defensive = [p for p in team if p["defensive"]] - offensive = [p for p in team if not p["defensive"]] - rng.shuffle(defensive) - rng.shuffle(offensive) - return defensive + offensive - - -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"] - base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)] - return base + d["per_surviving_unit"] * len(survivors) diff --git a/backend/tft/sim/game.py b/backend/tft/sim/game.py index 4035d0c..b09b5a5 100644 --- a/backend/tft/sim/game.py +++ b/backend/tft/sim/game.py @@ -2,8 +2,7 @@ import random -from tft.model.score import board_profiles -from tft.sim import combat, economy, player, policy +from tft.sim import arena, economy, player, policy from tft.sim.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401 from tft.sim.pool import Pool from tft.sim.rounds import schedule @@ -11,6 +10,22 @@ from tft.sim.rounds import schedule BOT_NAMES = ["Aatrox", "Belle", "Cyrus", "Dana", "Eiko", "Finn", "Gwen"] +def _flip(log: dict) -> dict: + """Seiten tauschen, damit der Spieler in der Anzeige immer "a" ist. + + Die Reihen müssen mitgespiegelt werden. Nur die Beschriftung zu + tauschen würde die eigenen Units auf der Gegnerhälfte zeichnen. + """ + swap = {"a": "b", "b": "a"} + last = log["rows"] - 1 + events = [{**e, "row": last - e["row"]} if "row" in e else e + for e in log["events"]] + return {**log, + "winner": swap.get(log["winner"]) if log["winner"] else None, + "units": [{**u, "side": swap[u["side"]]} for u in log["units"]], + "events": events} + + def pair_players(alive: list[PlayerState], rng: random.Random): """PvP-Paarung: kein Wiederholungsgegner bei >2 Lebenden; ungerade -> Ghost.""" order = list(alive) @@ -31,7 +46,12 @@ class Game: def __init__(self, artifact: dict, cfg: dict, seed: int | None = None): self.artifact = artifact self.cfg = cfg + # Der Seed bleibt gemerkt: nur so lässt sich ein Kampf später + # mit demselben Ausgang nachspielen. + self.seed = seed self.rng = random.Random(seed) + # Der letzte Kampf des Spielers, als Protokoll für die Anzeige. + self.last_battle = None self.pool = Pool(artifact["static"]["units"], cfg["pool"]["sizes"]) self.rounds = schedule(cfg) self.idx = 0 @@ -70,8 +90,8 @@ class Game: def over(self) -> bool: return self.player.placement is not None - def player_score(self) -> float: - return player.score(self.player, self.artifact) + def player_strength(self) -> dict: + return player.strength(self.player, self.artifact) def _alive(self) -> list[PlayerState]: return [p for p in self.players if p.alive] @@ -132,7 +152,10 @@ class Game: player.buy_xp(self.player, self.cfg) def move(self, where: str, idx: int) -> None: - player.move(self.player, where, idx) + player.move(self.player, where, idx, self.artifact) + + def place(self, where: str, idx: int, hexid: int) -> None: + player.place(self.player, where, idx, hexid) def equip(self, item_idx: int, board_idx: int) -> None: player.equip(self.player, item_idx, board_idx, self.artifact) @@ -241,14 +264,15 @@ class Game: results[b.name] = a_wins is False if odd is not None: # Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler. - res = combat.fight(board_profiles(odd.board, self.artifact), - board_profiles(ghost_src.board, self.artifact), - self.rng, self.cfg) + res, log = arena.fight(odd.board, ghost_src.board, self.artifact, + self.rng, record=odd is self.player) + if log: + self.last_battle = log won = res.winner == "a" results[odd.name] = won if not won: survivors = res.survivors_b if res.winner == "b" else [] - dmg = combat.player_damage(rnd["stage"], survivors, self.cfg) + dmg = arena.player_damage(rnd["stage"], survivors, self.cfg) odd.hp -= dmg if odd is self.player: self.log.append( @@ -258,12 +282,15 @@ class Game: return results def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool | None: - res = combat.fight(board_profiles(a.board, self.artifact), - board_profiles(b.board, self.artifact), - self.rng, self.cfg) + mine = a is self.player or b is self.player + res, log = arena.fight(a.board, b.board, self.artifact, self.rng, + record=mine) + if log: + # Aus Sicht des Spielers protokollieren: "a" ist immer er. + self.last_battle = log if a is self.player else _flip(log) if res.winner is None: # Unentschieden: beide nehmen den Basis-Schaden der Stage. - dmg = combat.player_damage(rnd["stage"], [], self.cfg) + dmg = arena.player_damage(rnd["stage"], [], self.cfg) a.hp -= dmg b.hp -= dmg if a is self.player or b is self.player: @@ -273,7 +300,7 @@ class Game: a_wins = res.winner == "a" winner, loser = (a, b) if a_wins else (b, a) survivors = res.survivors_a if a_wins else res.survivors_b - dmg = combat.player_damage(rnd["stage"], survivors, self.cfg) + dmg = arena.player_damage(rnd["stage"], survivors, self.cfg) loser.hp -= dmg if a is self.player or b is self.player: if winner is self.player: diff --git a/backend/tft/sim/hexgrid.py b/backend/tft/sim/hexgrid.py new file mode 100644 index 0000000..955feef --- /dev/null +++ b/backend/tft/sim/hexgrid.py @@ -0,0 +1,88 @@ +"""Das Spielfeld: 7 Spalten, 8 Reihen, versetzte Sechsecke. + +TFT stellt die Reihen versetzt an. Reihe 0 und 2 beginnen bündig, Reihe 1 +und 3 sind eine halbe Zelle nach rechts geschoben. Für Entfernungen taugt +das direkt nicht — zwei Felder mit demselben Spaltenabstand liegen je nach +Reihe unterschiedlich weit auseinander. + +Deshalb wird intern in Würfelkoordinaten gerechnet. Dort ist die Distanz +die halbe Summe der Beträge, und die sechs Nachbarn sind sechs feste +Richtungen. Umgerechnet wird an genau einer Stelle, damit sich der Fehler +nicht durch den Kampf zieht. + +Reihen 0-3 gehören dem Angreifer, 4-7 dem Verteidiger. +""" + +COLS = 7 +ROWS = 8 +HALF = ROWS // 2 + +# Die sechs Nachbarn in Würfelkoordinaten. +DIRECTIONS = ((1, -1, 0), (1, 0, -1), (0, 1, -1), + (-1, 1, 0), (-1, 0, 1), (0, -1, 1)) + + +def valid(col: int, row: int) -> bool: + return 0 <= col < COLS and 0 <= row < ROWS + + +def to_cube(col: int, row: int) -> tuple: + """Versetzte Reihen -> Würfelkoordinaten ("odd-r"-Anordnung).""" + x = col - (row - (row & 1)) // 2 + z = row + return (x, -x - z, z) + + +def _raw_distance(a: tuple, b: tuple) -> int: + ax, ay, az = to_cube(*a) + bx, by, bz = to_cube(*b) + return (abs(ax - bx) + abs(ay - by) + abs(az - bz)) // 2 + + +# Das Feld hat 56 Zellen, also 3.136 Paare. Einmal vorrechnen spart im +# Kampf über 100.000 Umrechnungen je Duell. +_DISTANCE = {(a, b): _raw_distance(a, b) + for a in ((c, r) for r in range(ROWS) for c in range(COLS)) + for b in ((c, r) for r in range(ROWS) for c in range(COLS))} + + +def distance(a: tuple, b: tuple) -> int: + """Entfernung in Feldern zwischen zwei (Spalte, Reihe)-Paaren.""" + found = _DISTANCE.get((a, b)) + return found if found is not None else _raw_distance(a, b) + + +_NEIGHBOURS: dict = {} + + +def neighbours(col: int, row: int) -> list[tuple]: + """Die begehbaren Nachbarfelder, immer in derselben Reihenfolge. + + Die feste Reihenfolge ist wichtig: ohne sie hinge der Ausgang eines + Kampfes davon ab, wie ein Dictionary gerade sortiert ist. + """ + cached = _NEIGHBOURS.get((col, row)) + if cached is not None: + return cached + x, y, z = to_cube(col, row) + out = [] + for dx, dy, dz in DIRECTIONS: + nx, nz = x + dx, z + dz + ncol = nx + (nz - (nz & 1)) // 2 + if valid(ncol, nz): + out.append((ncol, nz)) + _NEIGHBOURS[(col, row)] = out + return out + + +def mirror(col: int, row: int) -> tuple: + """Ein Feld auf die andere Hälfte spiegeln. + + Im Spiel steht der Gegner gespiegelt gegenüber; nur so heißt "vorne" + für beide Seiten dasselbe. + """ + return (COLS - 1 - col, ROWS - 1 - row) + + +def own_half(row: int, side: str) -> bool: + return row < HALF if side == "a" else row >= HALF diff --git a/backend/tft/sim/lineup.py b/backend/tft/sim/lineup.py new file mode 100644 index 0000000..7a76ca0 --- /dev/null +++ b/backend/tft/sim/lineup.py @@ -0,0 +1,49 @@ +"""Aufstellung: Board-Einträge auf Felder setzen. + +Die Endboards aus der Riot-API tragen keine Positionen. Für den Kampf +braucht es aber welche. Deshalb eine feste Regel: Nahkämpfer nach vorne, +Fernkämpfer nach hinten, innerhalb einer Reihe von der Mitte nach außen. + +Die Regel ist eine Annahme, keine Messung — echte Spieler stellen anders +auf. Sie ist nur deterministisch und plausibel, damit derselbe Board +immer dieselbe Aufstellung ergibt. +""" + +from tft.sim import hexgrid as H + +# Reihe 0 ist die vorderste der eigenen Hälfte. +MELEE_RANGE = 1 +# Von der Mitte nach außen füllen — so steht der Carry geschützt. +COLUMN_ORDER = (3, 2, 4, 1, 5, 0, 6) + + +def _rows(side: str) -> list[int]: + """Reihen der eigenen Hälfte, vorderste zuerst.""" + if side == "a": + return [H.HALF - 1, H.HALF - 2, H.HALF - 3, H.HALF - 4] + return [H.HALF, H.HALF + 1, H.HALF + 2, H.HALF + 3] + + +def place(board: list[dict], side: str, static_units: dict) -> list[dict]: + """Board-Einträge mit col/row versehen. Reihenfolge bleibt erhalten. + + Nahkämpfer bekommen die vorderen Reihen, Fernkämpfer die hinteren. + Innerhalb einer Reihe wird von der Mitte nach außen gefüllt. + """ + def reach(entry): + stats = (static_units.get(entry.get("api_name")) or {}).get("stats") or {} + return int(stats.get("range") or MELEE_RANGE) + + front = [e for e in board if reach(e) <= MELEE_RANGE] + back = [e for e in board if reach(e) > MELEE_RANGE] + rows = _rows(side) + cells = [(col, row) for row in rows for col in COLUMN_ORDER] + + out = [] + # Nahkämpfer von vorne, Fernkämpfer von hinten — sie treffen sich + # in der Mitte, wenn das Board voll ist. + for entry, cell in zip(front, cells): + out.append({**entry, "col": cell[0], "row": cell[1]}) + for entry, cell in zip(back, reversed(cells[len(front):])): + out.append({**entry, "col": cell[0], "row": cell[1]}) + return out diff --git a/backend/tft/sim/player.py b/backend/tft/sim/player.py index ca12174..98d7159 100644 --- a/backend/tft/sim/player.py +++ b/backend/tft/sim/player.py @@ -3,13 +3,17 @@ import random from dataclasses import dataclass, field -from tft.model.score import score_board +# Die vier eigenen Reihen mal sieben Spalten. Das Kampffeld in +# `hexgrid` hat acht Reihen — vier je Seite. +BOARD_ROWS = 4 +BOARD_COLS = 7 from tft.sim import economy from tft.sim.pool import Pool from tft.sim.shop import roll BENCH_SIZE = 9 MAX_ITEMS_PER_UNIT = 3 +BOARD_HEXES = BOARD_ROWS * BOARD_COLS class InvalidAction(Exception): @@ -52,8 +56,11 @@ def new_player(name: str, archetype: str, cfg: dict) -> PlayerState: ) -def score(p: PlayerState, artifact: dict) -> float: - return score_board(p.board, p.augments, artifact) +def strength(p: PlayerState, artifact: dict) -> dict: + """Offensiv-/Defensivstärke des Boards, 1.0 = Ø-Champion auf 1★.""" + from tft.sim import arena + + return arena.strength(p.board, artifact) def unit_worth(artifact: dict, u: dict) -> float: @@ -70,11 +77,14 @@ def merge(p: PlayerState, api_name: str, stars: int) -> None: if u["api_name"] == api_name and u["stars"] == stars] if len(copies) < 3: return - keep, *rest = copies + # Genau 3 Kopien verschmelzen — überzählige bleiben als eigene Unit stehen. + keep, *rest = copies[:3] keep["stars"] += 1 for u in rest: keep["items"].extend(u["items"]) (p.board if u in p.board else p.bench).remove(u) + # Mehr als 3 Items: Überschuss zurück in die Leiste statt verfallen lassen. + p.items.extend(keep["items"][MAX_ITEMS_PER_UNIT:]) keep["items"] = keep["items"][:MAX_ITEMS_PER_UNIT] stars += 1 @@ -131,7 +141,20 @@ def buy_xp(p: PlayerState, cfg: dict) -> None: p.level, p.xp = economy.apply_xp(p.level, p.xp, xp["buy_amount"], cfg) -def move(p: PlayerState, where: str, idx: int) -> None: +def is_ranged(artifact: dict, u: dict) -> bool: + unit = artifact["static"]["units"].get(u["api_name"]) or {} + return (unit.get("stats") or {}).get("range", 1) > 1 + + +def free_hex(p: PlayerState, back_first: bool = False) -> int | None: + """Nächster freier Hex. Fernkämpfer fangen hinten an, Nahkämpfer vorn.""" + rows = reversed(range(BOARD_ROWS)) if back_first else range(BOARD_ROWS) + taken = {u.get("hex") for u in p.board} + order = [r * BOARD_COLS + c for r in rows for c in range(BOARD_COLS)] + return next((h for h in order if h not in taken), None) + + +def move(p: PlayerState, where: str, idx: int, artifact: dict | None = None) -> None: src, dst = (p.bench, p.board) if where == "bench" else (p.board, p.bench) if not (0 <= idx < len(src)): raise InvalidAction("no unit there") @@ -139,7 +162,45 @@ def move(p: PlayerState, where: str, idx: int) -> None: raise InvalidAction("board full for your level") if dst is p.bench and len(p.bench) >= BENCH_SIZE: raise InvalidAction("bench full") - dst.append(src.pop(idx)) + unit = src.pop(idx) + if dst is p.board: + unit["hex"] = free_hex(p, artifact is not None and is_ranged(artifact, unit)) + else: + unit["hex"] = None + dst.append(unit) + + +def place(p: PlayerState, where: str, idx: int, hexid: int) -> None: + """Unit auf ein Hex ziehen — von der Bank oder innerhalb des Kampffelds.""" + if not (0 <= hexid < BOARD_HEXES): + raise InvalidAction("no such hex") + # Eine Bedingung für beide Zweige — sonst prüft der eine gegen das Board + # und der andere greift auf die Bank zu. + from_bench = where == "bench" + src = p.bench if from_bench else p.board + if not (0 <= idx < len(src)): + raise InvalidAction("no unit there") + unit = src[idx] + occupant = next((u for u in p.board if u.get("hex") == hexid), None) + if occupant is unit: + return + + if not from_bench: + # Tausch innerhalb des Felds: der Belegte übernimmt das alte Hex. + if occupant is not None: + occupant["hex"] = unit["hex"] + unit["hex"] = hexid + return + + if occupant is None and len(p.board) >= p.level: + raise InvalidAction("board full for your level") + p.bench.pop(idx) + if occupant is not None: + p.board.remove(occupant) + occupant["hex"] = None + p.bench.append(occupant) + unit["hex"] = hexid + p.board.append(unit) def equip(p: PlayerState, item_idx: int, board_idx: int, artifact: dict | None = None) -> None: @@ -200,7 +261,9 @@ def fill_board(p: PlayerState, artifact: dict, swap: bool = True) -> None: swap=False für den Menschen: seine Aufstellung wird nie überstimmt.""" p.bench.sort(key=lambda u: -unit_worth(artifact, u)) while len(p.board) < p.level and p.bench: - p.board.append(p.bench.pop(0)) + unit = p.bench.pop(0) + unit["hex"] = free_hex(p, is_ranged(artifact, unit)) + p.board.append(unit) if not swap: return for i, u in enumerate(p.board): @@ -209,5 +272,10 @@ def fill_board(p: PlayerState, artifact: dict, swap: bool = True) -> None: best = max(p.bench, key=lambda b: unit_worth(artifact, b)) if unit_worth(artifact, best) > unit_worth(artifact, u): p.bench.remove(best) + u["hex"] = None p.bench.append(u) + # Neu einsortieren statt das Hex zu erben — sonst landet ein + # Fernkämpfer in der Front, nur weil dort vorher jemand stand. + best["hex"] = None p.board[i] = best + best["hex"] = free_hex(p, is_ranged(artifact, best)) diff --git a/backend/tft/sim/pool.py b/backend/tft/sim/pool.py index 4e6cdf8..34644d0 100644 --- a/backend/tft/sim/pool.py +++ b/backend/tft/sim/pool.py @@ -8,7 +8,10 @@ class Pool: for api, u in static_units.items() if 1 <= u["cost"] <= len(sizes) } - self.cost_of = {api: u["cost"] for api, u in static_units.items()} + # Nur Units, die auch im Pool liegen — sonst KeyError in units_of_cost, + # sobald ein Set Kostenstufen außerhalb von sizes mitbringt. + self.cost_of = {api: u["cost"] for api, u in static_units.items() + if api in self.copies} def available(self, api_name: str) -> int: return self.copies.get(api_name, 0) diff --git a/backend/tft/sim/traits.py b/backend/tft/sim/traits.py new file mode 100644 index 0000000..2821328 --- /dev/null +++ b/backend/tft/sim/traits.py @@ -0,0 +1,158 @@ +"""Trait-Boni auf die Kämpfer rechnen. + +Die Statikdaten geben je Trait und Stufe einen Satz Variablen mit freien +Namen — `DefenderDefenseGain`, `HunterAD`, `BrawlerHealthPercent`. Es +gibt kein Schema dazu. Deshalb hier eine Tabelle, die Namensteile auf +Werte abbildet, und zwei Regeln, die aus den Daten abgelesen sind: + + * Enthält der Name "Team", gilt der Bonus für das ganze Board. + Sonst nur für die Units mit diesem Trait. + * Werte unter 1 sind Anteile, Werte darüber flache Zahlen. Das ist + nicht schön, aber belegt: `Blossom.ADAP` läuft von 0.12 bis 1.0, + `Fae.ADAP` steht bei 5 und 8. Derselbe Name, zwei Skalen. + +Dreizehn der 36 Traits haben gar keine Variablen — Beschwörer, Wandler +und ähnliche Verhaltens-Traits. Die fehlen hier und sind unten gelistet, +damit klar ist, was die Engine noch nicht kann. +""" + +from collections import Counter + +# Namensteil -> Wirkung. Reihenfolge zählt, der erste Treffer gewinnt. +# "ADAP" muss vor "AD" stehen, sonst greift die kürzere Regel. +RULES = ( + ("ADAP", ("attack_damage_pct", "ability_power_pct")), + ("HunterAD", ("attack_damage_pct",)), + ("CapstoneAD", ("attack_damage_pct",)), + ("CapstoneAP", ("ability_power_pct",)), + ("CapstoneAspd", ("attack_speed_pct",)), + ("CapstoneArmor", ("armor_flat",)), + ("CapstoneMR", ("magic_resist_flat",)), + ("CapstoneHealth", ("health_flat",)), + ("DefenseGain", ("armor_flat", "magic_resist_flat")), + ("Durability", ("durability",)), + ("HealthPercent", ("health_pct",)), + ("HealthIncrease", ("health_pct",)), + ("BonusHealth", ("health_flat",)), + ("MaxHealthShield", ("shield_pct",)), + ("Health", ("health_flat",)), + ("AttackSpeedIncrease", ("attack_speed_pct",)), + ("AttackSpeed", ("attack_speed_pct",)), + ("ASPerAttack", ("attack_speed_pct",)), + ("AbilityPower", ("ability_power_pct",)), + ("AP", ("ability_power_pct",)), + ("BonusDamagePercent", ("damage_amp",)), + ("CritChance", ("crit_chance",)), + ("ManaRegen", ("mana_regen",)), + ("ManaBonus", ("mana_flat",)), + ("Mana", ("mana_flat",)), +) + +# Ab hier gilt ein Wert als flache Zahl statt als Anteil. +FRACTION_LIMIT = 1.0 +# Diese Wirkungen sind immer Anteile, auch bei großen Zahlen. +ALWAYS_FRACTION = ("durability", "damage_amp", "shield_pct", "crit_chance") + + +def effects_of(variables: dict) -> dict: + """Variablen einer Trait-Stufe -> Wirkungen. Unbekannte fallen weg.""" + out = Counter() + for name, value in (variables or {}).items(): + if not isinstance(value, (int, float)): + continue + target = next((fields for part, fields in RULES if part in name), None) + if not target: + continue + for field in target: + amount = float(value) + if field.endswith("_pct") and amount > FRACTION_LIMIT: + amount /= 100.0 + out[field] += amount + return dict(out) + + +def counts(board: list[dict], static_units: dict) -> Counter: + """Trait -> Zahl der Träger auf dem Board. + + Gezählt werden Champions, nicht Kopien: zwei Exemplare desselben + Champions bringen den Trait nur einmal. Das UI muss dieselbe Zählung + benutzen, sonst zeigt es Stufen an, die im Kampf nicht wirken. + """ + out = Counter() + seen = set() + for entry in board: + api = entry.get("api_name") + if api in seen or api not in static_units: + continue + seen.add(api) + out.update(static_units[api].get("traits") or ()) + return out + + +def active(board: list[dict], static_units: dict, static_traits: dict) -> dict: + """Trait -> erreichte Stufe und ihre Variablen.""" + counts_ = counts(board, static_units) + + out = {} + for trait, count in counts_.items(): + info = static_traits.get(trait) + if not info: + continue + reached = None + for ordinal, step in enumerate(info.get("breakpoints") or [], start=1): + if count >= step.get("min_units", 0): + # "step" ist die erreichte Stufe, 1-basiert — das UI zeigt + # sie an, und die Zählung muss zum Kampf passen. + reached = {**step, "step": ordinal, "units": count} + if reached: + out[trait] = reached + return out + + +def apply(fighters: list, board: list[dict], static_units: dict, + static_traits: dict) -> dict: + """Boni auf die Kämpfer rechnen. Gibt die aktiven Traits zurück. + + Trägt der Name "Team", gilt der Bonus für alle. Sonst nur für die + Units, die den Trait selbst haben. + """ + tiers = active(board, static_units, static_traits) + for trait, step in tiers.items(): + variables = step.get("variables") or {} + team_wide = {k: v for k, v in variables.items() if "Team" in k} + own_only = {k: v for k, v in variables.items() if "Team" not in k} + for scope, group in ((team_wide, fighters), + (own_only, [f for f in fighters + if trait in (static_units.get(f.key) or {}) + .get("traits", ())])): + gains = effects_of(scope) + if not gains: + continue + for one in group: + _grant(one, gains) + return tiers + + +def _grant(one, gains: dict) -> None: + one.attack_damage *= 1.0 + gains.get("attack_damage_pct", 0.0) + one.attack_speed *= 1.0 + gains.get("attack_speed_pct", 0.0) + one.ability_power += gains.get("ability_power_pct", 0.0) * 100.0 + one.armor += gains.get("armor_flat", 0.0) + one.magic_resist += gains.get("magic_resist_flat", 0.0) + one.crit_chance += gains.get("crit_chance", 0.0) + one.mana_regen += gains.get("mana_regen", 0.0) + one.max_mana = max(1.0, one.max_mana - gains.get("mana_flat", 0.0)) + one.damage_amp += gains.get("damage_amp", 0.0) + # Härte wirkt wie ein Abschlag auf allen erlittenen Schaden. + one.durability = min(0.9, getattr(one, "durability", 0.0) + + gains.get("durability", 0.0)) + bonus = (gains.get("health_flat", 0.0) + + one.max_hp * gains.get("health_pct", 0.0)) + one.max_hp += bonus + one.hp = one.max_hp + # Schilde zählen hier als zusätzliches Leben — die Engine kennt noch + # keinen eigenen Schild-Zustand. + shield = gains.get("shield_pct", 0.0) + if shield: + one.max_hp *= 1.0 + shield + one.hp = one.max_hp diff --git a/backend/tft/staticdata/fetch.py b/backend/tft/staticdata/fetch.py index cb82f7a..52f6d17 100644 --- a/backend/tft/staticdata/fetch.py +++ b/backend/tft/staticdata/fetch.py @@ -6,6 +6,7 @@ from datetime import date import requests from tft import paths +from tft.staticdata import icons, metatft from tft.staticdata.parse import parse CDRAGON = "https://raw.communitydragon.org/latest" @@ -13,19 +14,8 @@ METADATA_URL = f"{CDRAGON}/content-metadata.json" TFT_DATA_URL = f"{CDRAGON}/cdragon/tft/en_us.json" -def fetch_static(set_override: int | None = None) -> dict: - meta = requests.get(METADATA_URL, timeout=30) - meta.raise_for_status() - version = meta.json()["version"] - patch = ".".join(version.split(".")[:2]) - - resp = requests.get(TFT_DATA_URL, timeout=120) - resp.raise_for_status() - raw = resp.json() - - parsed = parse(raw, set_override=set_override) +def _write(parsed: dict, raw: dict, patch: str, version: str | None) -> dict: set_number = parsed["meta"]["set"] - out_dir = paths.static_dir(set_number, patch) out_dir.mkdir(parents=True, exist_ok=True) @@ -43,13 +33,54 @@ def fetch_static(set_override: int | None = None) -> dict: return parsed +def fetch_metatft(set_number: int) -> dict: + """Set-Daten von MetaTFT holen — die Quelle, die ein neues Set zuerst hat.""" + resp = requests.get(metatft.SOURCE_URL.format(set=set_number), timeout=120) + resp.raise_for_status() + raw = resp.json() + + parsed = metatft.parse(raw) + if parsed["meta"]["set"] != set_number: + raise SystemExit( + f"metatft returned set {parsed['meta']['set']}, asked for {set_number}" + ) + found = icons.resolve(parsed, set_number) + print(" icons:", ", ".join(f"{k} {v}/{len(parsed[k])}" for k, v in found.items())) + return _write(parsed, raw, parsed["meta"]["source_patch"] or "unknown", None) + + +def fetch_static(set_override: int | None = None) -> dict: + meta = requests.get(METADATA_URL, timeout=30) + meta.raise_for_status() + version = meta.json()["version"] + patch = ".".join(version.split(".")[:2]) + + resp = requests.get(TFT_DATA_URL, timeout=120) + resp.raise_for_status() + raw = resp.json() + + parsed = parse(raw, set_override=set_override) + return _write(parsed, raw, patch, version) + + def load_static() -> dict: """Load the most recently fetched static data.""" from pathlib import Path pointer = json.loads(paths.latest_static_pointer().read_text()) static_dir = Path(pointer["dir"]) - return { + static = { name: json.loads((static_dir / f"{name}.json").read_text()) for name in ("units", "traits", "items", "augments", "meta") } + # Optional: aus einer gespeicherten Tierlist importierte Meta-Werte. + tiers = static_dir / "tierlist.json" + static["tierlist"] = json.loads(tiers.read_text()) if tiers.exists() else {} + return static + + +def static_dir_of_latest(): + """Verzeichnis der zuletzt geholten Statikdaten.""" + from pathlib import Path + + return Path(json.loads(paths.latest_static_pointer().read_text())["dir"]) diff --git a/backend/tft/staticdata/icons.py b/backend/tft/staticdata/icons.py new file mode 100644 index 0000000..045249f --- /dev/null +++ b/backend/tft/staticdata/icons.py @@ -0,0 +1,157 @@ +"""Icon-URLs für MetaTFT-Tabellen aus den cdragon-Assets nachtragen. + +MetaTFT liefert nur Asset-Namen ("T_18_Ahri_Square"), keine Pfade. Die Bilder +selbst liegen aber bei cdragon — nur eben unter Dateinamen, die je Champion +eine andere Skin-Nummer tragen. Deshalb listen wir die Verzeichnisse auf +statt Pfade zu raten: falsch geratene URLs liefern 404 und damit stumme +Leerkacheln im UI. +""" + +import re +from concurrent.futures import ThreadPoolExecutor + +import requests + +from tft.staticdata.parse import CDRAGON_GAME + +LISTING = "https://raw.communitydragon.org/json/latest/game/{path}/" +TRAIT_ICONS = "assets/ux/traiticons" +ITEM_ICON_DIRS = [ + "assets/maps/tft/icons/items/hexcore", + "assets/maps/tft/icons/augments/hexcore", + *(f"assets/maps/particles/tft/item_icons/{d}" for d in + ("standard", "ornn_items", "radiant", "shadow", "consumables", + "augments", "pairs")), +] +CHARACTER_IMAGES = "assets/characters/{character}/skins/base/images" + +# Item-Icons kommen von Riots Data Dragon statt von cdragon. Grund: cdragon +# kennt die Set-18-Items gar nicht (Striker's Flail, Protector's Vow und +# Steadfast Heart fehlen im ganzen Baum), und die Namenssuche traf ersatzweise +# falsche Bilder aus alten Sets. Data Dragon liefert sie unter genau dem +# apiName, den auch die Riot-API benutzt — kein Raten mehr. +DDRAGON_VERSIONS = "https://ddragon.leagueoflegends.com/api/versions.json" +DDRAGON_ITEMS = "https://ddragon.leagueoflegends.com/cdn/{v}/data/en_US/tft-item.json" +DDRAGON_IMAGE = "https://ddragon.leagueoflegends.com/cdn/{v}/img/tft-item/{file}" + + +def ddragon_items() -> dict: + """api_name -> Bild-URL. Leer, wenn Data Dragon nicht erreichbar ist.""" + try: + version = requests.get(DDRAGON_VERSIONS, timeout=30).json()[0] + data = requests.get(DDRAGON_ITEMS.format(v=version), timeout=30).json() + except (requests.RequestException, ValueError, IndexError, KeyError): + return {} + return {key.rsplit("/", 1)[-1]: + DDRAGON_IMAGE.format(v=version, file=entry["image"]["full"]) + for key, entry in data.get("data", {}).items()} + + +def listing(path: str) -> list[str]: + """Dateinamen eines cdragon-Verzeichnisses; leer, wenn es das nicht gibt.""" + try: + resp = requests.get(LISTING.format(path=path), timeout=30) + resp.raise_for_status() + return [e["name"] for e in resp.json() if e.get("type") == "file"] + except (requests.RequestException, ValueError): + return [] + + +def index(paths: list[str]) -> dict: + """Dateiname ohne Endung (kleingeschrieben) -> vollständige URL.""" + out = {} + for path in paths: + for name in listing(path): + out.setdefault(name.rsplit(".", 1)[0].lower(), f"{CDRAGON_GAME}/{path}/{name}") + return out + + +def _slug(text: str) -> str: + return re.sub(r"[^a-z0-9]", "", (text or "").lower()) + + +def _key(text: str | None) -> str: + """Asset-Name -> Index-Schlüssel: kleingeschrieben, Endung weg.""" + return (text or "").lower().rsplit(".", 1)[0] + + +def _item_keys(api_name: str, asset: str | None) -> list[str]: + """Kandidaten-Dateinamen; Items überleben Sets unter wechselnden Präfixen.""" + base = re.sub(r"^(DA|TFT\d*)_", "", api_name) + base = re.sub(r"^(Component|Artifact|Item)_", "", base) + asset = re.sub(r"^T_(Item_)?", "", asset or "") + stem = re.sub(r"^Artifact_", "", asset) + return [k.lower() for k in ( + api_name, f"tft_item_{base}", f"tft_item_{asset}", f"tft_item_{stem}", + f"tft_item_artifact_{stem}", base, asset, stem, + ) if k] + + +ROMAN = {1: "i", 2: "ii", 3: "iii"} + + +def _augment_keys(asset: str | None, tier: int | None) -> list[str]: + """Augment-Icons heißen '_.png'.""" + stem = re.sub(r"_AugmentMasks$", "", re.sub(r"^T_AugmentIcon_", "", asset or "")) + slug = _slug(stem) + return [k for k in (f"{slug}_{ROMAN.get(tier, '')}", slug, _key(stem), + *(f"{slug}_{r}" for r in ROMAN.values())) if k] + + +def resolve(tables: dict, set_number: int) -> dict: + """Icons in-place eintragen. Gibt zurück, wie viele je Tabelle gefunden wurden.""" + trait_icons = index([TRAIT_ICONS]) + prefix = f"trait_icon_{set_number}_" + for trait in tables["traits"].values(): + # Der Asset-Name aus MetaTFT trifft am genauesten; das Set-Suffix + # ("..._TFT_Set18") trägt der Dateiname nicht. + asset = re.sub(r"_tft_set\d+$", "", _key(trait.pop("icon_asset", None))) + trait["icon"] = next( + (trait_icons[k] for k in + (asset, prefix + _slug(trait["name"]), prefix + _slug(trait["api_name"])) + if k in trait_icons), None) + + item_icons = index(ITEM_ICON_DIRS) + ddragon = ddragon_items() + for table in ("items", "augments"): + for entry in tables[table].values(): + asset = entry.pop("icon_asset", None) + keys = (_augment_keys(asset, entry.get("tier")) if table == "augments" + else _item_keys(entry["api_name"], asset)) + entry["icon"] = ( + ddragon.get(entry["api_name"]) if table == "items" else None + ) or next((item_icons[k] for k in keys if k in item_icons), None) + + units = list(tables["units"].values()) + with ThreadPoolExecutor(8) as pool: + found = list(pool.map(_unit_files, units)) + for unit, files in zip(units, found): + unit.pop("icon_asset", None) + # splash_tile ist der quadratische Zuschnitt (Bank, Board, Liste), + # splash_centered die breite Szene für die Shop-Karte. Die + # Monster-Units haben nur ein TeamPlanner-Splash. + unit["icon"] = _pick(files, "_splash_tile_") or _pick(files, "teamplannersplash") + unit["splash"] = _pick(files, "_splash_centered_") or unit["icon"] + + return { + "traits": sum(1 for t in tables["traits"].values() if t["icon"]), + "items": sum(1 for i in tables["items"].values() if i["icon"]), + "augments": sum(1 for a in tables["augments"].values() if a["icon"]), + "units": sum(1 for u in tables["units"].values() if u["icon"]), + } + + +def _unit_files(unit: dict) -> tuple[str, list[str]]: + character = (unit.get("icon_asset") or unit["api_name"]).lower() + for name in (character, re.sub(r"_base$", "", character)): + path = CHARACTER_IMAGES.format(character=name) + files = listing(path) + if files: + return path, files + return path, [] + + +def _pick(files: tuple[str, list[str]], marker: str) -> str | None: + path, names = files + name = next((n for n in sorted(names) if marker in n), None) + return f"{CDRAGON_GAME}/{path}/{name}" if name else None diff --git a/backend/tft/staticdata/metatft.py b/backend/tft/staticdata/metatft.py new file mode 100644 index 0000000..72fca80 --- /dev/null +++ b/backend/tft/staticdata/metatft.py @@ -0,0 +1,298 @@ +"""Parse MetaTFT's set lookup into the same tables as the cdragon parser. + +Community Dragon veröffentlicht die Champions eines neuen Sets erst mit dem +Live-Patch — für Set 18 stehen dort nur zwei Units. MetaTFT baut dieselben +Spieldateien schon ab PBE aus und stellt sie öffentlich bereit. + +Ausgabe-Shape ist identisch mit parse.parse(), damit Artefakt, Sim und UI +unverändert bleiben. Unterschiede zur cdragon-Quelle: + +* Tooltips nutzen statt @Platzhalter@. +* Werte je Sternstufe stehen als dünn besetzte [stufe, wert]-Paare in + curveValues — fehlende Stufen erben den vorherigen Wert. +* Items tragen ihre Stats in statLine, nicht in effects. +* Icons sind Asset-Namen ohne Pfad; die füllt fetch.resolve_icons nach. +""" + +import re + +SOURCE_URL = "https://data.metatft.com/lookups/TFTSet{set}_latest_en_us.json" +TABLES_URL = "https://data.metatft.com/lookups/{channel}_TFTSet{set}_tables.json" + +# +CURVE_RE = re.compile(r"]*?)/>", re.I) +ATTR_RE = re.compile(r"]*?)/>", re.I) +XML_ATTRS_RE = re.compile(r"(\w+)=\"([^\"]*)\"") +TAG_RE = re.compile(r"<[^>]+>") + +MAX_STARS = 3 + +# statLine-Icon -> Effekt-Key, den statsheet._apply_item_effects kennt. +ICON_TO_EFFECT = { + "ad": "AD", "ap": "AP", "as": "AS", "health": "Health", "armor": "Armor", + "mr": "MagicResist", "manaregen": "ManaRegen", "critchance": "CritChance", + "critdmg": "CritDamageToGive", "damageamp": "DamageAmp", +} +# Zielkonvention je Key (siehe statsheet-Docstring): Fraction, Prozentzahl +# oder flach. MetaTFT liefert alles als Fraction, sobald ein format gesetzt ist. +EFFECT_AS_PERCENT_NUMBER = {"AP", "AS", "CritChance"} + +AUGMENT_TIERS = {"silver": 1, "gold": 2, "prismatic": 3} + + +def xml_attrs(fragment: str) -> dict: + return {k.lower(): v for k, v in XML_ATTRS_RE.findall(fragment)} + + +def curve_at(curve: list, column: int) -> float | None: + """Wert einer Kurve in Spalte N. Kurven sind Treppen: fehlende Spalten + erben den letzten kleineren Eintrag.""" + value = None + for col, val in curve or []: + if col <= column: + value = val + else: + break + return value + + +def star_array(curve: list) -> list: + """Kurve -> [0, 1★, 2★, 3★] in cdragon-Konvention (Index 1..3 = Sterne).""" + return [0] + [curve_at(curve, star) for star in range(1, MAX_STARS + 1)] + + +PERCENT_FORMATS = ("percent", "percentminusone", "invertedpercent") + + +def as_fraction(value: float, fmt: str | None) -> float: + """Rohwert -> Anteil, gesteuert vom format-Attribut des Tooltips. + + percent 0.25 -> 0.25 (Anteil) + percentMinusOne 1.10 -> 0.10 (Faktor, z.B. Angriffstempo) + invertedPercent 0.96 -> 0.04 (Resthärte, z.B. Durability) + ohne format 175 -> 175 (flacher Wert) + """ + fmt = (fmt or "").lower() + if fmt == "percentminusone": + return value - 1 + if fmt == "invertedpercent": + return 1 - value + return value + + +def render_desc(desc: str | None, curves: dict, attribute_values: dict | None = None) -> str: + """Tooltip-Tags durch ihre Werte ersetzen, Markup entfernen.""" + + def curve_repl(m: re.Match) -> str: + at = xml_attrs(m.group(1)) + value = curve_at(curves.get(at.get("row")), int(at.get("column") or 1)) + if value is None: + return "?" + fmt = (at.get("format") or "").lower() + if fmt in PERCENT_FORMATS: + value = as_fraction(value, fmt) * 100 + return str(int(value)) if float(value).is_integer() else str(round(value, 1)) + + def attr_repl(m: re.Match) -> str: + at = xml_attrs(m.group(1)) + values = (attribute_values or {}).get(at.get("attributeid")) + return "/".join(str(int(v)) for v in values[:MAX_STARS]) if values else "?" + + text = CURVE_RE.sub(curve_repl, desc or "") + text = ATTR_RE.sub(attr_repl, text) + return re.sub(r"\s+", " ", TAG_RE.sub(" ", text)).strip() + + +DAMAGE_ATTR_RE = re.compile(r"DamageCalc\d*$", re.I) +# "Attacks deal " / "Each attack deals ": Schaden pro Auto-Attacke +# statt pro Cast — der Mana-Zyklus gilt dafür nicht. +ON_ATTACK_RE = re.compile(r"attacks?\s+(?:also\s+)?deals?|per attack|each attack", re.I) + + +def spell_tags(ability: dict) -> list[tuple[dict, list, int]]: + """Alle Schadens-Tags des Tooltips als (Attribute, Werte, Position). + + Nur Tags, deren attributeID wirklich in attributeValues steht und auf + DamageCalc endet. Der erste Tag im Text ist oft ein Mana- oder + Stat-Wert — bei Lux stünde sonst 3 statt 330 im Modell. + """ + values = ability.get("attributeValues") or {} + out = [] + for match in ATTR_RE.finditer(ability.get("desc") or ""): + at = xml_attrs(match.group(1)) + name = at.get("attributeid") or "" + array = values.get(name) + if array and DAMAGE_ATTR_RE.search(name): + out.append((at, array, match.start())) + return out + + +def resolve_spell(unit: dict) -> dict: + """Spell-Schaden je Stern aus attributeValues. + + Eine Fähigkeit nennt oft mehrere Schadenszahlen (Haupttreffer, Splash, + Sonderfall). Wir nehmen die größte auf 1★ — das ist der Treffer, der die + Fähigkeit ausmacht. icon= sagt, ob sie mit AD, AP oder beidem skaliert. + """ + ability = unit.get("ability") or {} + tags = spell_tags(ability) + if not tags: + return {"spell_damage": None, "spell_damage_type": None, + "spell_scaling": None, "spell_on_attack": False} + + at, array, pos = max(tags, key=lambda t: t[1][0]) + icons = (at.get("icon") or "").lower() + has_ad, has_ap = "icon.ad" in icons, "icon.ap" in icons + style = (at.get("style") or "").lower() + context = (ability.get("desc") or "")[max(0, pos - 70):pos] + + return { + # MetaTFT zählt ab Index 0 = 1★; cdragon ab Index 1. + "spell_damage": [0] + list(array[:MAX_STARS]), + "spell_damage_type": ("physical" if "physical" in style else + "true" if "true" in style else "magic"), + "spell_scaling": ("both" if has_ad and has_ap else + "ad" if has_ad else "ap" if has_ap else None), + "spell_on_attack": bool(ON_ATTACK_RE.search(context)), + } + + +def ability_vars(unit: dict) -> dict: + """Beschreibende Kurven der Fähigkeit als Werte je Sternstufe. + + Nur die im Tooltip referenzierten Zeilen — der Rest sind ungenutzte + Reste aus der Kurventabelle. + """ + ability = unit.get("ability") or {} + curves = unit.get("curveValues") or {} + rows = {xml_attrs(m.group(1)).get("row") + for m in CURVE_RE.finditer(ability.get("desc") or "")} + return {row: star_array(curves[row]) for row in sorted(rows) if row in curves} + + +def trait_variables(effect: dict, curves: dict, ordinal: int) -> dict: + """Kurvenwerte einer Trait-Stufe, benannt nach ihrer Kurvenzeile. + + statsheet.trait_buffs erkennt Stats am Namen, daher bleibt der + Zeilenname (z.B. BrawlerHealthPercent) erhalten. + """ + out = {} + for m in CURVE_RE.finditer(effect.get("desc") or ""): + at = xml_attrs(m.group(1)) + row = at.get("row") + value = curve_at(curves.get(row), int(at.get("column") or ordinal)) + if value is not None: + out[row] = as_fraction(value, at.get("format")) + return out + + +def item_effects(item: dict) -> dict: + """statLine -> Effekt-Dict in der Werte-Konvention von statsheet.""" + curves = item.get("curveValues") or {} + out = {} + for m in CURVE_RE.finditer(item.get("statLine") or ""): + at = xml_attrs(m.group(1)) + key = ICON_TO_EFFECT.get((at.get("icon") or "").split(".")[-1].lower()) + value = curve_at(curves.get(at.get("row")), 1) + if key is None or value is None: + continue + value = as_fraction(value, at.get("format")) + out[key] = out.get(key, 0) + (value * 100 if key in EFFECT_AS_PERCENT_NUMBER + else value) + return out + + +def parse(raw: dict) -> dict: + """MetaTFT-Lookup -> {meta, units, traits, items, augments}.""" + set_number = int(re.sub(r"\D", "", raw["_metadata"]["set"])) + roles = raw.get("roles") or {} + + units, incomplete = {}, [] + for u in raw["units"]: + # shopUnit trennt kaufbare Champions von PvE-Monstern, Summons und + # Verwandlungsformen (TFT18_EliseSpider) sauberer als "hat Traits". + if not u.get("shopUnit") or not u.get("traits"): + continue + if not (u.get("stats") or {}).get("hp"): + # Lücke im PBE-Dump. Ohne HP wäre die eHP 0 und die Unit wertlos — + # lieber weglassen und melden als still verzerren. + incomplete.append(f"{u['name']} (hp)") + continue + if not (u.get("stats") or {}).get("damage"): + # Auto-Angriff fehlt, Fähigkeit und Verteidigung zählen weiter. + # Nicht auslassen: die Units werden gespielt, nur ihr Auto-DPS ist 0. + incomplete.append(f"{u['name']} (damage)") + units[u["apiName"]] = { + "api_name": u["apiName"], + "name": u["name"], + "cost": u["cost"], + "traits": list(u["traitApiNames"]), + # "DA_Role_MagicCaster" -> "MagicCaster"; baseline.classify_role + # liest daraus dieselben Rollen wie aus cdragons "ADFighter". + "role": re.sub(r"^\w+_Role_", "", u.get("role") or "") + or roles.get(u.get("role")), + # Die Match-API nennt Units beim Asset-Namen ("DA_18_GnarSmall"), + # nicht beim apiName. Ohne diese Liste findet learn.py nichts wieder. + "asset_names": list(u.get("assetNames") or []), + "stats": dict(u["stats"]), + "ability_name": (u.get("ability") or {}).get("name"), + "ability_vars": ability_vars(u), + "icon": None, + "splash": None, + "icon_asset": u.get("characterName") or u["apiName"], + **resolve_spell(u), + } + + traits = {} + for t in raw["traits"]: + curves = t.get("curveValues") or {} + traits[t["apiName"]] = { + "api_name": t["apiName"], + "name": t["name"], + "breakpoints": [ + {"min_units": e["minUnits"], "style": e.get("style") or 1, + "variables": trait_variables(e, curves, i)} + for i, e in enumerate(t.get("effects") or [], start=1) + ], + "icon": None, + "icon_asset": t.get("icon"), + } + + known_traits = set(traits) + for u in units.values(): + u["traits"] = [t for t in u["traits"] if t in known_traits] + + items, augments = {}, {} + for i in raw["items"]: + items[i["apiName"]] = { + "api_name": i["apiName"], + "name": i["name"], + "composition": list(i.get("composition") or []), + "effects": item_effects(i), + "icon": None, + # Asset-Name ohne Pfad; icons.resolve macht daraus eine URL. + "icon_asset": i.get("icon"), + } + for a in raw["augments"]: + curves = a.get("curveValues") or {} + augments[a["apiName"]] = { + "api_name": a["apiName"], + "name": a["name"], + "composition": [], + # Kurvenzeilen sind hier die Effekt-Werte (GoldInstantBasic: 20). + "effects": {row: curve_at(curve, 1) for row, curve in curves.items()}, + "tier": AUGMENT_TIERS.get((a.get("rarity") or "").lower()), + "desc": render_desc(a.get("desc"), curves, a.get("attributeValues")), + "icon": None, + "icon_asset": a.get("icon"), + } + + return { + "meta": {"set": set_number, "set_name": raw["_metadata"]["setName"], + "source": "metatft", "source_patch": raw["_metadata"].get("patch"), + "incomplete_units": incomplete}, + "units": units, + "traits": traits, + "items": items, + "augments": augments, + } diff --git a/backend/tft/staticdata/parse.py b/backend/tft/staticdata/parse.py index e55b166..bcf42ef 100644 --- a/backend/tft/staticdata/parse.py +++ b/backend/tft/staticdata/parse.py @@ -106,11 +106,12 @@ def resolve_spell(ability: dict) -> dict: def icon_url(asset_path: str) -> str: - p = asset_path.lower() - for ext in (".tex", ".dds"): - if p.endswith(ext): - p = p[: -len(ext)] + ".png" - return f"{CDRAGON_GAME}/{p}" + """cdragon serviert alles kleingeschrieben als PNG und ohne Set-Suffix: + '..._Splash_Tile_27.TFT_Set17.tex' -> '..._splash_tile_27.png'.""" + path = asset_path.lower().replace("\\", "/") + head, _, name = path.rpartition("/") + name = f"{name.split('.')[0]}.png" + return f"{CDRAGON_GAME}/{head}/{name}" if head else f"{CDRAGON_GAME}/{name}" def detect_set(raw: dict) -> int: @@ -138,7 +139,15 @@ def parse(raw: dict, set_override: int | None = None) -> dict: "role": c.get("role"), "stats": c["stats"], "ability_name": c["ability"]["name"], - "icon": icon_url(c["squareIcon"]), + # Rohe Fähigkeits-Variablen (Wert je Sternstufe, Index 1..3). + # Welche davon wie ein Dauer-Stat wirken, entscheidet statsheet. + "ability_vars": {v["name"]: v["value"] + for v in c["ability"].get("variables") or [] + if isinstance(v.get("value"), list)}, + # tileIcon = HUD-Quadrat für Units, Bank und Spielerliste. + # squareIcon = Splash-Kachel, füllt die Shop-Karte wie im Spiel. + "icon": icon_url(c["tileIcon"]), + "splash": icon_url(c["squareIcon"]), **resolve_spell(c["ability"]), } diff --git a/backend/tft/staticdata/procomps.py b/backend/tft/staticdata/procomps.py new file mode 100644 index 0000000..1d46c25 --- /dev/null +++ b/backend/tft/staticdata/procomps.py @@ -0,0 +1,67 @@ +"""Pro-Comps von MetaTFT holen. + +Die Liste stammt von einem Spieler, nicht aus Statistik — sie sagt, was +zusammen gespielt wird, nicht wie es ausgeht. Genau dafür brauchen wir +sie: welche Champions gehören zusammen? + +Der Endpunkt liefert ein Dokument mit Comps und ihren Stufen (Level 4 +bis 9). Jede Stufe zählt einzeln, das ist mehr Signal als nur das +Endboard. +""" + +import json + +import requests + +from tft.staticdata.fetch import static_dir_of_latest + +URL = "https://api-hc.metatft.com/tft-stat-api/pro-comps" +CACHE = "procomps.json" +MIN_UNITS = 4 + + +def fetch() -> dict: + """Rohdokument holen und neben den Statikdaten ablegen. + + Bei Netzfehler kommt der zuletzt gespeicherte Stand zurück — der + Artefakt-Bau soll daran nicht scheitern. + """ + path = static_dir_of_latest() / CACHE + try: + doc = requests.get(URL, timeout=60).json() + except (requests.RequestException, ValueError): + return json.loads(path.read_text()) if path.exists() else {} + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(doc)) + return doc + + +def comps(doc: dict, static_units: dict) -> list[dict]: + """Jede Comp-Stufe als {"tier", "title", "units"}. + + Die Namen im Dokument sind Riots Asset-Namen, unsere Schlüssel nicht. + Deshalb dieselbe Alias-Tabelle wie bei den Endboards. + """ + alias = {} + for key, unit in static_units.items(): + for name in (unit.get("asset_names") or []) + [key]: + alias[name] = key + + tier_of = {entry["data"]["$ref"].rsplit("/", 1)[-1]: entry.get("tier") + for entry in (doc.get("content") or {}).get("comps") or []} + + out = [] + for ref in doc.get("refs") or []: + content = ref.get("content") or {} + tier = tier_of.get(ref.get("content_id")) + title = (ref.get("metadata") or {}).get("title", "") + stages = [content.get("unit_positions") or {}] + stages += [v.get("unit_positions") or {} + for v in (content.get("variations") or []) + if v.get("type") == "comp"] + for positions in stages: + units = sorted({alias[slot["apiName"]] for slot in positions.values() + if slot.get("apiName") in alias}) + if len(units) >= MIN_UNITS: + out.append({"tier": tier, "title": title, "units": units}) + return out diff --git a/backend/tft/staticdata/tierlist.py b/backend/tft/staticdata/tierlist.py new file mode 100644 index 0000000..314c003 --- /dev/null +++ b/backend/tft/staticdata/tierlist.py @@ -0,0 +1,68 @@ +"""Meta-Stärke der Units aus einer gespeicherten MetaTFT-Tierlist. + +Off/Def sagen, was eine Unit auf dem Papier kann. Die Tierlist sagt, was sie +in echten Spielen bringt: die durchschnittliche Platzierung aller Boards, auf +denen sie am Ende stand. + +Die Seite rendert clientseitig, deshalb die im Browser gespeicherte HTML. +Zeilen mit winziger Stichprobe (Skin-Varianten, Verwandlungsformen) fliegen +raus — bei 3 Spielen ist eine 1.20er Platzierung Rauschen. +""" + +import html +import re + +ROW_RE = re.compile(r'class="TableItemRow"') +TAG_RE = re.compile(r"<[^>]+>") +NUM_RE = re.compile(r"^\d[\d,]*\.?\d*%?$") +# "Lux (Blossom)" ist dieselbe Unit mit anderem Trait, keine eigene Zeile wert. +VARIANT_RE = re.compile(r"\s*\(.*\)$") + +MIN_GAMES = 1000 +# Platzierung 1 ist das Beste, 8 das Schlechteste — die Skala ist invertiert. +WORST_PLACE = 8.0 + + +def _cells(fragment: str) -> list[str]: + lines = html.unescape(TAG_RE.sub("\n", fragment)).splitlines() + return [x.strip() for x in lines if x.strip() and not x.startswith("class=")] + + +def parse(page: str) -> dict: + """HTML -> {Unit-Name: {"avg_place", "win_rate", "games", "pick_rate"}}.""" + starts = [m.start() for m in ROW_RE.finditer(page)] + [len(page)] + rows = {} + for start, end in zip(starts, starts[1:]): + cells = _cells(page[start:end]) + if not cells or cells[0] == "Unit": + continue + nums = [c for c in cells if NUM_RE.match(c)] + if len(nums) < 4: + continue + games = int(nums[2].replace(",", "")) + name = VARIANT_RE.sub("", cells[0]) + if games < MIN_GAMES or games <= rows.get(name, {}).get("games", 0): + continue + rows[name] = { + "avg_place": float(nums[0]), + "win_rate": float(nums[1].rstrip("%")) / 100, + "games": games, + "pick_rate": float(nums[3].rstrip("%")) / 100, + } + return rows + + +def meta_strength(rows: dict, static_units: dict) -> dict: + """api_name -> Meta-Stärke, 1.0 = durchschnittliche Unit des Sets. + + Gerechnet wird auf dem Abstand zum letzten Platz, nicht auf der + Platzierung selbst: 2.6 statt 3.6 ist ein Drittel mehr Vorsprung, + 3.6 durch 2.6 wäre nur eine willkürliche Verhältniszahl. + """ + by_name = {u["name"]: api for api, u in static_units.items() if u.get("name")} + edge = {by_name[n]: WORST_PLACE - r["avg_place"] + for n, r in rows.items() if n in by_name} + if not edge: + return {} + mean = sum(edge.values()) / len(edge) + return {api: round(value / mean, 3) for api, value in edge.items()} diff --git a/backend/uv.lock b/backend/uv.lock index dd6ed88..421c9fb 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -195,6 +195,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -362,6 +435,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, + { name = "numpy" }, { name = "requests" }, { name = "uvicorn" }, ] @@ -375,6 +449,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi" }, + { name = "numpy" }, { name = "requests" }, { name = "uvicorn" }, ] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ce1e79c..69f1898 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,53 +1,109 @@