From c450841822ef88c27a067572f86f2d92960fae01 Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 08:52:56 +0200 Subject: [PATCH] =?UTF-8?q?Mechanisches=20Boardst=C3=A4rke-Modell:=20Stats?= =?UTF-8?q?heet=20aus=20cdragon-Exaktdaten,=20sqrt(eHP=C3=97DPS),=20Tier-A?= =?UTF-8?q?nker,=20Tau-Fit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/B auf 38 Holdout-Matches: mechanical 0.724 vs legacy 0.695. Promotion durchgeführt. Co-Authored-By: Claude Fable 5 --- backend/tests/test_parse.py | 77 +++++++++++++++ backend/tests/test_score.py | 56 ++++++++++- backend/tests/test_statsheet.py | 73 ++++++++++++++ backend/tft/cli.py | 18 +++- backend/tft/constants/set17.toml | 5 +- backend/tft/model/artifact.py | 24 +++++ backend/tft/model/calibrate.py | 53 +++++++++-- backend/tft/model/score.py | 104 +++++++++++++++++--- backend/tft/model/statsheet.py | 159 +++++++++++++++++++++++++++++++ backend/tft/staticdata/parse.py | 67 ++++++++++++- 10 files changed, 605 insertions(+), 31 deletions(-) create mode 100644 backend/tests/test_parse.py create mode 100644 backend/tests/test_statsheet.py create mode 100644 backend/tft/model/statsheet.py diff --git a/backend/tests/test_parse.py b/backend/tests/test_parse.py new file mode 100644 index 0000000..43aa3a6 --- /dev/null +++ b/backend/tests/test_parse.py @@ -0,0 +1,77 @@ +from tft.staticdata.parse import parse, resolve_spell + +RAW = { + "sets": { + "17": { + "name": "Set17", + "champions": [ + { + "apiName": "TFT17_Mage", + "name": "Mage", + "cost": 2, + "traits": ["Caster"], + "role": "Caster", + "stats": {"hp": 600, "armor": 25, "magicResist": 25, "damage": 40, + "attackSpeed": 0.7, "mana": 60, "initialMana": 10}, + "squareIcon": "ASSETS/x.TFT_Set17.tex", + "ability": { + "name": "Blast", + "desc": "Deal @ModifiedDamage@ (%i:scaleAP%) magic damage.", + "variables": [{"name": "Damage", "value": [0, 200, 300, 450, 700, 0, 0]}], + }, + }, + ], + "traits": [ + { + "apiName": "TFT17_Caster", + "name": "Caster", + "icon": "ASSETS/t.tex", + "effects": [ + {"minUnits": 2, "maxUnits": 3, "style": 1, + "variables": {"BonusAP": 20.0}}, + ], + }, + ], + }, + }, + "items": [ + { + "apiName": "TFT_Item_TestSword", + "name": "Test Sword", + "composition": ["TFT_Item_A", "TFT_Item_B"], + "effects": {"AD": 0.35, "CritChance": 35.0}, + "icon": "ASSETS/i.tex", + }, + ], +} + + +def test_parse_keeps_effects_and_variables(): + parsed = parse(RAW, set_override=17) + assert parsed["items"]["TFT_Item_TestSword"]["effects"] == {"AD": 0.35, "CritChance": 35.0} + bp = parsed["traits"]["TFT17_Caster"]["breakpoints"][0] + assert bp["variables"] == {"BonusAP": 20.0} + + +def test_parse_resolves_spell_damage(): + unit = parse(RAW, set_override=17)["units"]["TFT17_Mage"] + assert unit["spell_damage"] == [0, 200, 300, 450, 700, 0, 0] + assert unit["spell_damage_type"] == "magic" + assert unit["spell_scaling"] == "ap" + + +def test_resolve_spell_adaptive_and_fallback(): + adaptive = { + "desc": "Deal @TotalDamage@ damage. %i:scaleAP% %i:scaleAD%", + "variables": [ + {"name": "ADDamage", "value": [0, 100, 150, 225, 0, 0, 0]}, + {"name": "APDamage", "value": [0, 50, 75, 110, 0, 0, 0]}, + ], + } + r = resolve_spell(adaptive) + assert r["spell_damage"][1] == 150 # 100 + 50 + assert r["spell_scaling"] == "both" + + utility = {"desc": "Grant a shield.", "variables": [ + {"name": "Shield", "value": [0, 300, 400, 500, 0, 0, 0]}]} + assert resolve_spell(utility)["spell_damage"] is None diff --git a/backend/tests/test_score.py b/backend/tests/test_score.py index a0bc786..c596203 100644 --- a/backend/tests/test_score.py +++ b/backend/tests/test_score.py @@ -112,7 +112,61 @@ def test_stat_mults_identical_units_are_neutral(): assert all(m == 1.0 for m in mults.values()) -def test_stat_mult_raises_score(): +def test_stat_mult_raises_score_legacy(): + from tft.model.score import score_board_legacy as score_board + art = {**ARTIFACT, "stat_mults": {"TFT17_B": 1.1}} board = [{"api_name": "TFT17_B", "stars": 1, "items": []}] assert score_board(board, [], art) > score_board(board, [], ARTIFACT) + + +def _mech_artifact(): + from tft.model import artifact as artifact_mod + + static = { + "meta": {"set": 17, "patch": "test"}, + "units": { + "TANK": {"cost": 2, "traits": ["T_AD"], "role": "Tank", + "stats": {"hp": 900, "armor": 50, "magicResist": 50, "damage": 45, + "attackSpeed": 0.6, "mana": 60, "initialMana": 0}, + "spell_damage": None, "spell_scaling": None}, + "CARRY": {"cost": 2, "traits": ["T_AD"], "role": "Marksman", + "stats": {"hp": 600, "armor": 20, "magicResist": 20, "damage": 70, + "attackSpeed": 0.8, "mana": 50, "initialMana": 0}, + "spell_damage": [0, 250, 375, 560, 0, 0, 0], "spell_scaling": "ad"}, + }, + "traits": {"T_AD": {"breakpoints": [ + {"min_units": 2, "style": 1, "variables": {"BonusAP": 20.0}}]}}, + "items": {"SWORD": {"effects": {"AD": 0.35}}}, + "augments": {}, + } + return artifact_mod.build(static) + + +def test_mechanical_balanced_beats_lopsided(): + from tft.model.score import score_mechanical + art = _mech_artifact() + balanced = [{"api_name": "TANK", "stars": 1, "items": []}, + {"api_name": "CARRY", "stars": 1, "items": []}] + tanks = [{"api_name": "TANK", "stars": 1, "items": []} for _ in range(2)] + assert score_mechanical(balanced, [], art) > score_mechanical(tanks, [], art) + + +def test_mechanical_items_and_stars_raise_strength(): + from tft.model.score import score_mechanical + art = _mech_artifact() + base = [{"api_name": "CARRY", "stars": 1, "items": []}] + starred = [{"api_name": "CARRY", "stars": 2, "items": []}] + equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}] + assert score_mechanical(starred, [], art) > score_mechanical(base, [], art) + assert score_mechanical(equipped, [], art) > score_mechanical(base, [], art) + + +def test_mechanical_recognized_trait_buffs_stats(): + from tft.model.score import score_mechanical + art = _mech_artifact() + pair = [{"api_name": "TANK", "stars": 1, "items": []}, + {"api_name": "CARRY", "stars": 1, "items": []}] + solo_sum = (score_mechanical(pair[:1], [], art) + + score_mechanical(pair[1:], [], art)) + assert score_mechanical(pair, [], art) > solo_sum diff --git a/backend/tests/test_statsheet.py b/backend/tests/test_statsheet.py new file mode 100644 index 0000000..ad4f483 --- /dev/null +++ b/backend/tests/test_statsheet.py @@ -0,0 +1,73 @@ +import pytest + +from tft.model import statsheet + + +def unit(spell=None, scaling="ap", **overrides): + stats = {"hp": 700, "armor": 30, "magicResist": 30, "damage": 50, + "attackSpeed": 0.7, "mana": 60, "initialMana": 10, + "critChance": 0.25, "critMultiplier": 1.4} + stats.update(overrides) + return {"stats": stats, "spell_damage": spell, "spell_scaling": scaling} + + +ITEMS = { + "sword": {"effects": {"AD": 0.35, "CritChance": 35.0}}, + "vest": {"effects": {"Armor": 20.0}}, + "rod": {"effects": {"AP": 10.0}}, + "unmodeled": {"effects": {"BurnPercent": 1.0}}, +} + + +def test_star_scaling(): + one = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None) + two = statsheet.unit_stats(unit(), 2, [], ITEMS, None, None) + assert two["ehp"] == pytest.approx(one["ehp"] * 1.8) + assert two["dps"] == pytest.approx(one["dps"] * 1.5) + + +def test_spell_damage_uses_star_index_and_ap(): + spell = [0, 200, 300, 450, 0, 0, 0] + bare = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None) + with_rod = statsheet.unit_stats(unit(spell=spell), 1, ["rod"], ITEMS, None, None) + no_spell = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None) + assert bare["dps"] > no_spell["dps"] + assert with_rod["dps"] > bare["dps"] # +10 AP skaliert den Spell + + +def test_items_change_profile(): + base = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None) + sword = statsheet.unit_stats(unit(), 1, ["sword"], ITEMS, None, None) + vest = statsheet.unit_stats(unit(), 1, ["vest"], ITEMS, None, None) + assert sword["dps"] > base["dps"] + assert vest["ehp"] > base["ehp"] + assert statsheet.item_is_modeled(ITEMS["sword"]) + assert not statsheet.item_is_modeled(ITEMS["unmodeled"]) + + +def test_frontline_casts_more(): + spell = [0, 300, 450, 700, 0, 0, 0] + tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline") + carry = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "ad_carry") + assert tank["dps"] > carry["dps"] + + +def test_trait_buffs_keyword_mapping(): + traits = { + "T_AD": {"breakpoints": [{"min_units": 2, "style": 1, + "variables": {"BonusAD": 0.15}}]}, + "T_Mech": {"breakpoints": [{"min_units": 2, "style": 1, + "variables": {"Wolf_Gold": 1.0}}]}, + "T_Skip": {"breakpoints": [{"min_units": 2, "style": 1, + "variables": {"StunDuration": 1.5}}]}, + } + buffs, recognized = statsheet.trait_buffs( + {"T_AD": 1, "T_Mech": 1, "T_Skip": 1}, traits + ) + assert recognized == {"T_AD"} + assert buffs["ad_pct"] == pytest.approx(0.15) + + +def test_fraction_normalization(): + assert statsheet._fraction(0.15) == 0.15 + assert statsheet._fraction(15.0) == 0.15 diff --git a/backend/tft/cli.py b/backend/tft/cli.py index 33f2c84..4111338 100644 --- a/backend/tft/cli.py +++ b/backend/tft/cli.py @@ -22,7 +22,9 @@ def main() -> None: sub.add_parser("extract", help="extract endboards from raw matches") sub.add_parser("build-artifact", help="build analysis.json from static data + endboards") - sub.add_parser("calibrate", help="backtest score vs real placements (holdout)") + p_cal = sub.add_parser("calibrate", help="backtest score vs real placements (holdout)") + p_cal.add_argument("--compare", action="store_true", + help="A/B legacy vs. mechanical scorer + tau-Fit") p_auto = sub.add_parser("autoplay", help="run scripted games headless") p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ") @@ -81,7 +83,8 @@ def main() -> None: elif args.command == "calibrate": from tft import db from tft.model import artifact as artifact_mod - from tft.model.calibrate import calibrate + from tft.model.calibrate import calibrate, fit_tau + from tft.model.score import score_board_legacy, score_mechanical from tft.staticdata.fetch import load_static conn = db.connect() @@ -89,10 +92,19 @@ def main() -> None: baseline_art = artifact_mod.build(load_static(), {}) r_base = calibrate(conn, baseline_art, holdout_only=True) r_learned = calibrate(conn, learned_art, holdout_only=True) - conn.close() print(f"holdout matches: {r_learned['matches']}") print(f"spearman baseline: {r_base['mean_spearman']:.3f}") print(f"spearman learned: {r_learned['mean_spearman']:.3f}") + if args.compare: + r_leg = calibrate(conn, learned_art, holdout_only=True, score_fn=score_board_legacy) + r_mech = calibrate(conn, learned_art, holdout_only=True, score_fn=score_mechanical) + delta = r_mech["mean_spearman"] - r_leg["mean_spearman"] + tau = fit_tau(conn, learned_art, score_mechanical) + print(f"A/B legacy: {r_leg['mean_spearman']:.3f}") + print(f"A/B mechanical: {r_mech['mean_spearman']:.3f} (delta {delta:+.3f})") + print(f"tau-fit (mechanical): {tau['tau']} über {tau['pairs']} Paare, " + f"log-loss {tau.get('log_loss')}") + conn.close() elif args.command == "autoplay": from tft.constants.loader import load_constants diff --git a/backend/tft/constants/set17.toml b/backend/tft/constants/set17.toml index d9b6f68..de2939f 100644 --- a/backend/tft/constants/set17.toml +++ b/backend/tft/constants/set17.toml @@ -75,6 +75,7 @@ hp_multiplier = 1.8 ad_multiplier = 1.5 [combat] -# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)); tau wird in M6 kalibriert. -tau = 0.15 +# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)). +# tau = 0.28: Log-Loss-Fit über 1064 Holdout-Platzierungspaare (mechanical Scorer). +tau = 0.28 variance = 0.08 diff --git a/backend/tft/model/artifact.py b/backend/tft/model/artifact.py index ae83eeb..91fa63c 100644 --- a/backend/tft/model/artifact.py +++ b/backend/tft/model/artifact.py @@ -26,6 +26,29 @@ def craftable_items(static_items: dict) -> list[str]: return sorted(pool) +def tier_profiles(static: dict, roles: dict) -> dict: + """Mittleres Mechanik-Profil (eHP/DPS, 1★, itemlos) pro Kostenstufe. + + Anker für den mechanischen Scorer: Riot balanciert um die Kosten, + die Mechanik differenziert innerhalb der Stufe. + """ + from tft.model import statsheet + + by_cost: dict[int, list] = {} + for api, unit in static["units"].items(): + profile = statsheet.unit_stats( + unit, 1, [], static["items"], None, roles.get(api) + ) + by_cost.setdefault(unit["cost"], []).append(profile) + return { + str(cost): { + "ehp": sum(p["ehp"] for p in profiles) / len(profiles), + "dps": max(sum(p["dps"] for p in profiles) / len(profiles), 1.0), + } + for cost, profiles in by_cost.items() + } + + 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() @@ -46,6 +69,7 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N }, "roles": roles, "stat_mults": baseline.compute_stat_mults(static["units"]), + "tier_profiles": tier_profiles(static, roles), "item_pool": craftable_items(static["items"]), "learned": learned or {}, } diff --git a/backend/tft/model/calibrate.py b/backend/tft/model/calibrate.py index 924c37b..c4baac5 100644 --- a/backend/tft/model/calibrate.py +++ b/backend/tft/model/calibrate.py @@ -42,10 +42,7 @@ def board_from_row(units_json: str, augments_json: str) -> tuple[list[dict], lis return units, json.loads(augments_json) -def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict: - """Mean Spearman between board score and placement (negated: higher = better).""" - from tft.model.score import score_board - +def _holdout_scores(conn, artifact: dict, holdout_only: bool, score_fn) -> dict: set_number = artifact["meta"]["set"] where = "WHERE set_number = ?" if holdout_only: @@ -59,8 +56,17 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict: by_match: dict[str, list] = {} for match_id, placement, units_json, augments_json in rows: board, augments = board_from_row(units_json, augments_json) - s = score_board(board, augments, artifact) - by_match.setdefault(match_id, []).append((placement, s)) + by_match.setdefault(match_id, []).append( + (placement, score_fn(board, augments, artifact)) + ) + return by_match + + +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 + + by_match = _holdout_scores(conn, artifact, holdout_only, score_fn or score_board) correlations = [] for players in by_match.values(): @@ -75,3 +81,38 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict: "matches": n, "mean_spearman": sum(correlations) / n if n else 0.0, } + + +def fit_tau(conn, artifact: dict, score_fn) -> dict: + """Tau per Log-Loss über alle Platzierungspaare der Holdout-Matches fitten.""" + import math + + by_match = _holdout_scores(conn, artifact, True, score_fn) + pairs = [] + for players in by_match.values(): + if len(players) < 8: + continue + for i in range(len(players)): + for j in range(i + 1, len(players)): + (pl_a, s_a), (pl_b, s_b) = players[i], players[j] + mean = (s_a + s_b) / 2 + if mean <= 0: + continue + d = (s_a - s_b) / mean + pairs.append((d, pl_a < pl_b)) # kleinere Platzierung = besser + + if not pairs: + return {"tau": None, "pairs": 0} + + def log_loss(tau: float) -> float: + eps = 1e-9 + total = 0.0 + for d, a_wins in pairs: + p = 1 / (1 + math.exp(-d / tau)) + p = min(max(p, eps), 1 - eps) + total += -math.log(p if a_wins else 1 - p) + return total / len(pairs) + + taus = [t / 100 for t in range(2, 51, 2)] + best = min(taus, key=log_loss) + return {"tau": best, "pairs": len(pairs), "log_loss": round(log_loss(best), 4)} diff --git a/backend/tft/model/score.py b/backend/tft/model/score.py index 3343f2a..9016144 100644 --- a/backend/tft/model/score.py +++ b/backend/tft/model/score.py @@ -1,6 +1,6 @@ """The one board-scoring entry point. Sim, bots, calibration, and UI all call this.""" -from tft.model import baseline +from tft.model import baseline, statsheet def active_trait_tiers(board_units: list[dict], static_traits: dict, static_units: dict) -> dict: @@ -27,16 +27,30 @@ def active_trait_tiers(board_units: list[dict], static_traits: dict, static_unit return tiers -def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float: - """board_units: [{api_name, stars, items: [item api names]}].""" +def _apply_shared_multipliers(total: float, board_units: list[dict], augments: list[str], + learned: dict) -> float: + augment_mults = learned.get("augments", {}) + for augment in augments: + total *= augment_mults.get(augment, 1.0) + + pair_lifts = learned.get("pairs", {}) + names = sorted({u["api_name"] for u in board_units}) + lift_sum = sum( + pair_lifts.get(f"{a}|{b}", 0.0) + for i, a in enumerate(names) + for b in names[i + 1 :] + ) + return total * (1 + min(max(lift_sum * 0.01, -0.10), 0.10)) + + +def score_board_legacy(board_units: list[dict], augments: list[str], artifact: dict) -> float: + """Heuristik-Scorer: Kostenwert × Multiplikatoren.""" 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", {}) @@ -64,15 +78,75 @@ def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> ] total *= trait_mults.get(f"{trait}@{ordinal}", default) - for augment in augments: - total *= augment_mults.get(augment, 1.0) + return _apply_shared_multipliers(total, board_units, augments, learned) - names = sorted({u["api_name"] for u in board_units}) - lift_sum = sum( - pair_lifts.get(f"{a}|{b}", 0.0) - for i, a in enumerate(names) - for b in names[i + 1 :] - ) - total *= 1 + min(max(lift_sum * 0.01, -0.10), 0.10) - return total +def score_mechanical(board_units: list[dict], augments: list[str], artifact: dict) -> float: + """Kampf-Approximation: strength = sqrt(Σ eHP × Σ DPS) aus exakten Stats.""" + static_units = artifact["static"]["units"] + static_items = artifact["static"]["items"] + static_traits = artifact["static"]["traits"] + learned = artifact.get("learned", {}) + unit_mults = learned.get("units", {}) + item_mults = learned.get("items", {}) + trait_mults = learned.get("traits", {}) + roles = artifact.get("roles", {}) + + tiers = active_trait_tiers(board_units, static_traits, static_units) + team_buffs, recognized = statsheet.trait_buffs(tiers, static_traits) + tier_profiles = artifact.get("tier_profiles", {}) + + # Anker: 1★ itemlos zählt exakt "Kosten" in beiden Dimensionen. + # Mechanik (Stats, Items, Trait-Buffs, Sterne) verschiebt relativ dazu; + # Extraktions-Ausreißer werden pro Stufe gekappt. + RATIO_CAP = (0.5, 2.0) + + total_ehp = 0.0 + total_dps = 0.0 + for u in board_units: + unit = static_units.get(u["api_name"]) + if not unit: + continue + profile = statsheet.unit_stats( + unit, u["stars"], u.get("items", []), static_items, + team_buffs, roles.get(u["api_name"]), + ) + ref = tier_profiles.get(str(unit["cost"])) + base = baseline.unit_value(unit["cost"], u["stars"]) + if ref: + star_ehp = statsheet.HP_STAR_MULT ** (u["stars"] - 1) + star_dps = statsheet.AD_STAR_MULT ** (u["stars"] - 1) + r_ehp = profile["ehp"] / (ref["ehp"] * star_ehp) + r_dps = profile["dps"] / (ref["dps"] * star_dps) + r_ehp = min(max(r_ehp, RATIO_CAP[0]), RATIO_CAP[1]) + r_dps = min(max(r_dps, RATIO_CAP[0]), RATIO_CAP[1]) + else: + r_ehp = r_dps = 1.0 + mult = unit_mults.get(u["api_name"], 1.0) + total_ehp += mult * base * r_ehp + total_dps += mult * base * r_dps + + strength = (total_ehp * total_dps) ** 0.5 + + for trait, ordinal in tiers.items(): + key = f"{trait}@{ordinal}" + if trait in recognized: + # Buff steckt schon in den Stats — nur gelerntes Residuum. + strength *= trait_mults.get(key, 1.0) + else: + default = baseline.DEFAULT_TRAIT_TIER_MULT[ + min(ordinal, len(baseline.DEFAULT_TRAIT_TIER_MULT) - 1) + ] + strength *= trait_mults.get(key, default) + + for u in board_units: + for item in u.get("items", []): + modeled = statsheet.item_is_modeled(static_items.get(item)) + strength *= item_mults.get(item, 1.0 if modeled else baseline.DEFAULT_ITEM_MULT) + + return _apply_shared_multipliers(strength, board_units, augments, learned) + + +# Aktiver Scorer: mechanical (A/B 23.07.: 0.724 vs. legacy 0.695 auf 38 Holdout-Matches). +# Legacy bleibt für `calibrate --compare` erhalten. +score_board = score_mechanical diff --git a/backend/tft/model/statsheet.py b/backend/tft/model/statsheet.py new file mode 100644 index 0000000..34347c4 --- /dev/null +++ b/backend/tft/model/statsheet.py @@ -0,0 +1,159 @@ +"""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 + +MAPPED_ITEM_KEYS = ("AD", "AP", "AS", "CritChance", "Health", "Armor", + "MagicResist", "ManaRegen") + +_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, "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 + # 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) -> 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) + 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"] + 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 + + return {"ehp": ehp, "dps": (auto_dps + spell_dps) * (1 + acc["damage_amp"])} diff --git a/backend/tft/staticdata/parse.py b/backend/tft/staticdata/parse.py index aecc8c8..473f633 100644 --- a/backend/tft/staticdata/parse.py +++ b/backend/tft/staticdata/parse.py @@ -3,8 +3,67 @@ Fails loudly on missing keys — never guess through structure drift. """ +import re + CDRAGON_GAME = "https://raw.communitydragon.org/latest/game" +DAMAGE_TAG_RE = re.compile( + r"<(magicDamage|physicalDamage|trueDamage)>(.*?)", re.S +) +VAR_RE = re.compile(r"@([A-Za-z0-9_]+?)(?:\*[\d.]+)?@") +# Reihenfolge = Priorität; ADDamage/APDamage werden gesondert addiert. +DAMAGE_FALLBACKS = ( + "Damage", "DamageAP", "DamageAD", "SpellDamage", + "DamagePerSecond", "TrueDamagePerSecond", "BonusDamageOnAttack", +) +TAG_TO_TYPE = {"magicDamage": "magic", "physicalDamage": "physical", "trueDamage": "true"} + + +def resolve_spell(ability: dict) -> dict: + """Spell-Schaden pro Stern aus Desc-Markup + Variablen auflösen. + + Stern-Konvention der Arrays: value[1..3] = 1-3 Sterne (verifiziert). + """ + desc = ability.get("desc") or "" + variables = {v["name"]: v["value"] for v in ability.get("variables", []) + if v.get("value")} + + def get(name: str): + val = variables.get(name) + return val if val and any(val) else None + + dmg_type = None + array = None + for m in DAMAGE_TAG_RE.finditer(desc): + for vm in VAR_RE.finditer(m.group(2)): + name = vm.group(1) + array = get(name) or get(name.removeprefix("Modified")) + if array: + dmg_type = TAG_TO_TYPE[m.group(1)] + break + if array: + break + + if array is None: + ad, ap = get("ADDamage"), get("APDamage") + if ad and ap: + array = [a + b for a, b in zip(ad, ap)] + else: + for name in DAMAGE_FALLBACKS: + array = get(name) + if array: + break + + has_ap = "%i:scaleAP%" in desc + has_ad = "%i:scaleAD%" in desc + scaling = "both" if has_ap and has_ad else "ap" if has_ap else "ad" if has_ad else None + + return { + "spell_damage": array, + "spell_damage_type": dmg_type or ("magic" if array else None), + "spell_scaling": scaling, + } + def icon_url(asset_path: str) -> str: p = asset_path.lower() @@ -39,10 +98,8 @@ def parse(raw: dict, set_override: int | None = None) -> dict: "role": c.get("role"), "stats": c["stats"], "ability_name": c["ability"]["name"], - "ability_variables": { - v["name"]: v["value"] for v in c["ability"]["variables"] - }, "icon": icon_url(c["squareIcon"]), + **resolve_spell(c["ability"]), } traits = {} @@ -52,7 +109,8 @@ def parse(raw: dict, set_override: int | None = None) -> dict: "api_name": t["apiName"], "name": t["name"], "breakpoints": [ - {"min_units": e["minUnits"], "style": e["style"]} + {"min_units": e["minUnits"], "style": e["style"], + "variables": e.get("variables") or {}} for e in t["effects"] ], "icon": icon_url(t["icon"]), @@ -74,6 +132,7 @@ def parse(raw: dict, set_override: int | None = None) -> dict: "api_name": api, "name": i["name"], "composition": i["composition"], + "effects": i.get("effects") or {}, "icon": icon_url(i["icon"]) if i["icon"] else None, } if "Augment" in api: