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)>(.*?)\1>", 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: