+"""
+
+
+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 @@
-