From 7c72761190ffdededfbf531fe3f68985a2023225 Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 08:09:33 +0200 Subject: [PATCH] =?UTF-8?q?Unit-St=C3=A4rken=20pr=C3=A4zisiert:=20Stat-Pro?= =?UTF-8?q?xy-Multiplikatoren=20+=20gelernte=20Unit-Gewichte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/tests/test_learn.py | 12 +++++++++ backend/tests/test_score.py | 30 ++++++++++++++++++++++ backend/tft/model/artifact.py | 1 + backend/tft/model/baseline.py | 48 +++++++++++++++++++++++++++++++---- backend/tft/model/learn.py | 4 +++ backend/tft/model/score.py | 8 +++++- 6 files changed, 97 insertions(+), 6 deletions(-) diff --git a/backend/tests/test_learn.py b/backend/tests/test_learn.py index 78c8c83..037e600 100644 --- a/backend/tests/test_learn.py +++ b/backend/tests/test_learn.py @@ -50,3 +50,15 @@ def test_empty_rows(): assert learned["traits"] == {} assert learned["pairs"] == {} assert learned["n_boards"] == 0 + + +def test_unit_multipliers_learned(): + def row(placement, unit): + units = [{"character_id": unit, "tier": 2, "itemNames": []}] + return (placement, json.dumps(units), json.dumps([]), json.dumps([])) + + rows = [row(1, "TFT17_Strong") for _ in range(150)] + rows += [row(8, "TFT17_Weak") for _ in range(150)] + learned = learn(rows) + assert learned["units"]["TFT17_Strong"] > 1.05 + assert learned["units"]["TFT17_Weak"] < 0.95 diff --git a/backend/tests/test_score.py b/backend/tests/test_score.py index 12ef0cb..a0bc786 100644 --- a/backend/tests/test_score.py +++ b/backend/tests/test_score.py @@ -86,3 +86,33 @@ def test_craftable_item_pool(): "TFT_Item_Radiant": {"composition": None}, } assert craftable_items(items) == ["TFT_Item_InfinityEdge"] + + +def test_stat_mults_rank_within_cost(): + from tft.model.baseline import compute_stat_mults + + def unit(hp, armor, ad, aspd): + return {"cost": 2, "traits": [], "role": "Tank", + "stats": {"hp": hp, "armor": armor, "magicResist": armor, + "damage": ad, "attackSpeed": aspd, "mana": 60, + "initialMana": 0, "range": 1}} + + units = {"tanky": unit(900, 60, 45, 0.6), "avg": unit(700, 40, 55, 0.7), + "weak": unit(550, 25, 45, 0.6)} + mults = compute_stat_mults(units) + assert mults["tanky"] > mults["avg"] > mults["weak"] + assert all(0.9 <= m <= 1.1 for m in mults.values()) + + +def test_stat_mults_identical_units_are_neutral(): + from tft.model.baseline import compute_stat_mults + from tests.test_sim import make_static + + mults = compute_stat_mults(make_static()["units"]) + assert all(m == 1.0 for m in mults.values()) + + +def test_stat_mult_raises_score(): + art = {**ARTIFACT, "stat_mults": {"TFT17_B": 1.1}} + board = [{"api_name": "TFT17_B", "stars": 1, "items": []}] + assert score_board(board, [], art) > score_board(board, [], ARTIFACT) diff --git a/backend/tft/model/artifact.py b/backend/tft/model/artifact.py index c734ba0..ae83eeb 100644 --- a/backend/tft/model/artifact.py +++ b/backend/tft/model/artifact.py @@ -45,6 +45,7 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N "augments": static["augments"], }, "roles": roles, + "stat_mults": baseline.compute_stat_mults(static["units"]), "item_pool": craftable_items(static["items"]), "learned": learned or {}, } diff --git a/backend/tft/model/baseline.py b/backend/tft/model/baseline.py index 9605dc6..a4988fc 100644 --- a/backend/tft/model/baseline.py +++ b/backend/tft/model/baseline.py @@ -6,11 +6,49 @@ def unit_value(cost: int, stars: int) -> float: def stat_proxies(stats: dict) -> dict: - ehp = stats["hp"] * (1 + (stats["armor"] + stats["magicResist"]) / 2 / 100) - auto_dps = stats["damage"] * stats["attackSpeed"] - mana_gap = max(stats["mana"] - stats["initialMana"], 1) - cast_rate = stats["attackSpeed"] * 10 / mana_gap - return {"ehp": ehp, "auto_dps": auto_dps, "cast_rate": cast_rate} + # cdragon liefert für manche Units null-Werte — als 0 behandeln. + hp = stats.get("hp") or 0 + resists = ((stats.get("armor") or 0) + (stats.get("magicResist") or 0)) / 2 + ad = stats.get("damage") or 0 + aspd = stats.get("attackSpeed") or 0 + mana_gap = max((stats.get("mana") or 0) - (stats.get("initialMana") or 0), 1) + return { + "ehp": hp * (1 + resists / 100), + "auto_dps": ad * aspd, + "cast_rate": aspd * 10 / mana_gap, + } + + +STAT_MULT_RANGE = (0.9, 1.1) + + +def compute_stat_mults(static_units: dict) -> dict: + """Stat-Stärke relativ zur eigenen Kostenstufe: (eHP + Offense) / 2, gekappt. + + Offense = Maximum aus Auto-DPS und Cast-Rate (je normalisiert), damit + Caster nicht gegen Auto-Attacker abfallen. + """ + proxies = {api: stat_proxies(u["stats"]) for api, u in static_units.items()} + by_cost: dict[int, list[str]] = {} + for api, u in static_units.items(): + by_cost.setdefault(u["cost"], []).append(api) + + mults = {} + for apis in by_cost.values(): + n = len(apis) + avg = { + key: sum(proxies[a][key] for a in apis) / n or 1.0 + for key in ("ehp", "auto_dps", "cast_rate") + } + for a in apis: + ehp_norm = proxies[a]["ehp"] / avg["ehp"] + offense = max( + proxies[a]["auto_dps"] / avg["auto_dps"], + proxies[a]["cast_rate"] / avg["cast_rate"], + ) + raw = (ehp_norm + offense) / 2 + mults[a] = round(min(max(raw, STAT_MULT_RANGE[0]), STAT_MULT_RANGE[1]), 4) + return mults def classify_role(unit: dict) -> str: diff --git a/backend/tft/model/learn.py b/backend/tft/model/learn.py index 2504cb0..faed6d2 100644 --- a/backend/tft/model/learn.py +++ b/backend/tft/model/learn.py @@ -28,6 +28,7 @@ def learn(rows: list[tuple]) -> dict: trait_placements = defaultdict(list) item_placements = defaultdict(list) augment_placements = defaultdict(list) + unit_placements = defaultdict(list) unit_top4 = defaultdict(int) pair_top4 = defaultdict(int) n_boards = 0 @@ -40,6 +41,8 @@ def learn(rows: list[tuple]) -> dict: units = json.loads(units_json) names = sorted({u["character_id"] for u in units}) + for name in names: + unit_placements[name].append(placement) for u in units: for item in u.get("itemNames", []): item_placements[item].append(placement) @@ -69,6 +72,7 @@ def learn(rows: list[tuple]) -> dict: pairs[f"{a}|{b}"] = round(lift, 4) return { + "units": {k: round(_multiplier(v), 4) 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()}, diff --git a/backend/tft/model/score.py b/backend/tft/model/score.py index f48e3ec..3343f2a 100644 --- a/backend/tft/model/score.py +++ b/backend/tft/model/score.py @@ -32,18 +32,24 @@ def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> static_units = artifact["static"]["units"] static_traits = artifact["static"]["traits"] learned = artifact.get("learned", {}) + unit_mults = learned.get("units", {}) item_mults = learned.get("items", {}) trait_mults = learned.get("traits", {}) augment_mults = learned.get("augments", {}) pair_lifts = learned.get("pairs", {}) roles = artifact.get("roles", {}) + stat_mults = artifact.get("stat_mults", {}) total = 0.0 for u in board_units: unit = static_units.get(u["api_name"]) if not unit: continue - value = baseline.unit_value(unit["cost"], u["stars"]) + value = ( + baseline.unit_value(unit["cost"], u["stars"]) + * stat_mults.get(u["api_name"], 1.0) + * unit_mults.get(u["api_name"], 1.0) + ) role = roles.get(u["api_name"]) or baseline.classify_role(unit) for item in u.get("items", []): mult = item_mults.get(item, baseline.DEFAULT_ITEM_MULT)